第 6 章 · 结构化输出

Harness 锚点|判别清单第 2 项「工具执行」——用 Schema 约束工具的输入输出。

当你要的不是一段话,而是一份数据

那个跨境卖家想批量处理差评:让 Agent 读每条差评,判断它属于“物流”“质量”还是“描述不符”,给出情绪分值,再建议一个回复动作。他把差评喂给 Agent,得到的却是一段热情洋溢的自然语言:

“这条差评看起来主要是抱怨物流慢,客户情绪比较负面,建议您礼貌回复并提供补偿……”

读着挺顺,可他要的是能直接入库、能驱动后续程序的东西——一条能塞进工单系统的记录:{类别: "物流", 情绪: -0.8, 建议动作: "退运费券"}。一段散文没法喂给数据库,也没法触发自动化。

他需要的是结构化输出(Structured Output):让 Agent 返回固定格式的数据,而不是自由发挥的文本。

为什么结构化输出如此重要

自然语言适合给人看,但一旦 Agent 要接入更大的系统,“人话”就成了障碍。结构化输出让 Agent 的结果可以:

  • 直接入库:字段对齐数据库列,存进去就行。
  • 驱动程序if 类别 == "物流" 这样的判断,前提是“类别”是个确定的字段,而不是藏在一段话里。
  • 对接前端:页面按字段渲染卡片,不用去解析散文。
  • 喂给下一个 Agent:多 Agent 协作时(第 5 部分),上游的输出是下游的输入,结构化能让衔接不出错。
  • 稳定可测:结构固定,就能写测试断言“类别字段必须是三选一之一”。

一句话:只要 Agent 的输出要被“程序”而非“人”消费,就该用结构化输出。

核心手段:用 Schema 约束输出

让模型产出固定结构,靠的是一份 Schema(结构定义)——它规定了“输出必须长什么样”:有哪些字段、每个字段是什么类型、是否必填、取值范围。

回顾第 4 章的工具调用:我们用 JSON Schema 描述工具的输入参数。结构化输出用的是同一套机制,只不过约束的是输出

  • 你定义一份 Schema(比如“差评分析结果”含 category/sentiment/action 三个字段)。
  • 模型被要求严格按这份 Schema 生成 JSON。
  • 程序拿到的是一个保证合法的结构化对象,而非需要正则去抠的文本。

现代模型 API 大多原生支持这种“受约束生成”(constrained decoding / JSON mode),能从底层保证输出符合 Schema,而不只是“提示它输出 JSON、然后祈祷格式正确”。

用 pi 实现:工具即结构,JSON 模式即通道

pi 是一个 harness,它不像某些应用框架那样提供一个 response_model 一等参数。但结构化输出在 pi 里有两条自然、可靠的落地路径:

路径一(推荐):把结构化结果做成一个工具的输入。 这是最稳的做法——你想要什么结构,就定义一个“提交结果”的工具,把 Schema 放进它的参数里。模型“调用”这个工具时,就被强制按 Schema 填字段:

pi.registerTool({
  name: "submit_review_analysis",
  description: "提交一条差评的分析结果。分析完差评后必须调用它来输出结构化结论。",
  parameters: {
    type: "object",
    properties: {
      category: { type: "string", enum: ["物流", "质量", "描述不符", "其他"], description: "差评类别" },
      sentiment: { type: "number", description: "情绪分值,-1(极负面)到 1(极正面)" },
      action: { type: "string", enum: ["退运费券", "补发", "道歉说明", "无需处理"], description: "建议动作" },
      reason: { type: "string", description: "简短依据" },
    },
    required: ["category", "sentiment", "action"],
  },
  async execute(result) {
    await db.insert("review_analysis", result);   // 直接入库
    return { ok: true };
  },
});

模型读完差评,会调用 submit_review_analysisenum 和类型填好每个字段——category 只能是那四个值之一,sentiment 必是数字。你在 execute 里拿到的就是一个干净、合法的对象,直接入库。enum 锁死取值范围是这里的关键技巧:它把“物流/质量/描述不符”变成程序能判断的确定值。

路径二:JSON 事件流模式。 pi 提供 --mode json(见 json.md),把整个会话的事件以 JSON 行输出到 stdout。当你要把 pi 集成进别的程序、自己解析它的每一步(消息、工具调用、结果)时,这个模式让 pi 的运行过程本身就是结构化的、可被程序消费的。

pi --mode json "分析 tmp/reviews.txt 里的每条差评"
# stdout 逐行输出 JSON 事件:turn_start / tool_execution_start / ... 便于外部程序消费

路径一管“单次结果的结构”,路径二管“整个过程的结构”,按需选用。

多模态输入:图片和文件也要先变成结构

结构化输出不只服务纯文本。现实里的 Agent 经常要处理图片、PDF、截图、表格、录音转写、网页快照这些多模态输入。这时更要分清两件事:

  • 输入可以是多模态的:一条消息里可能同时有文本块、图片块、文件引用、工具结果块(第 12 章会讲 content block)。
  • 输出仍应尽量结构化:模型看完图片或文件后,最好交回一个稳定 schema,而不是一段自由描述。

例如让 Agent 处理一张商品主图,不要只让它“描述一下图片”,而是让它输出可入库的结构:

pi.registerTool({
  name: "submit_product_image_audit",
  description: "提交商品主图审核结果。看完图片后必须调用它输出结构化结论。",
  parameters: {
    type: "object",
    properties: {
      hasLogo: { type: "boolean", description: "图片是否包含品牌 logo" },
      background: { type: "string", enum: ["纯白", "场景图", "复杂背景", "不确定"] },
      visibleIssues: {
        type: "array",
        items: { type: "string" },
        description: "可见问题,如水印、低清晰度、主体被裁切"
      },
      confidence: { type: "number", description: "0 到 1 的置信度" }
    },
    required: ["hasLogo", "background", "visibleIssues", "confidence"]
  },
  async execute(result) {
    await db.insert("product_image_audits", result);
    return { ok: true };
  },
});

多模态在 Harness 里通常有三条进入路径:

输入类型推荐路径输出方式
PDF、Markdown、表格先解析/切片,必要时进 RAG(第 11 章)提取字段、引用来源、结构化摘要
图片、截图能用视觉模型就直接作为 content block;能 OCR/检测就先走工具审核结果、对象列表、置信度
网页/应用界面优先用 DOM / 无障碍树;拿不到结构才截图(第 31 章)当前页面状态、下一步动作、异常原因

关键不是“模型能不能看图”,而是看完以后系统怎么消费结果。如果结果要入库、触发流程、传给下游 Agent,就应该回到本章的原则:用 schema 锁住字段、类型和取值范围。

还要注意一个边界:多模态输入不是 Computer Use 本身。给模型一张商品图让它做审核,是多模态理解;让 Agent 盯着浏览器截图决定下一步点哪里,才是第 31 章的 Computer Use。前者偏“理解内容”,后者偏“操作环境”。两者都可能用图片,但工程风险不同:Computer Use 有真实副作用,更需要隔离和确认门。

动手看看

读 pi 的 packages/coding-agent/docs/json.md,看它定义了哪些事件类型(turn_starttool_execution_startcompaction_start……)。你会发现:pi 把 Agent 运行的每一个环节都设计成了结构化事件——这本身就是“结构化输出”在 harness 层面的体现,也是第 25 章可观测性的基础。

实战中的几个坑

坑一:让模型“输出 JSON”却不给 Schema。

  • 现象:只在提示里说“请返回 JSON”,结果格式时对时错、字段名飘忽。
  • 原因:没有 Schema 约束,模型只是在“模仿” JSON,不是被强制生成。
  • 对策:用工具参数或 API 的 JSON 模式给出明确 Schema,让底层受约束生成。

坑二:字段用自由字符串,程序没法判断。

  • 现象:category 返回“物流方面的问题”、“快递太慢了”,五花八门,if 判断全落空。
  • 原因:该用枚举的地方用了开放字符串。
  • 对策:能枚举就用 enum,把取值锁死成有限集合。

坑三:Schema 太复杂,模型填不对。

  • 现象:深层嵌套、几十个字段,模型漏填或填错。
  • 原因:一次要求太多,超出模型稳定发挥的范围。
  • 对策:拆小、扁平化;必填字段控制在少数几个,其余设为可选。

坑四:拿到结构就完全信任,不校验。

  • 现象:sentiment 偶尔返回了 1.5 这种越界值,直接入库污染数据。
  • 原因:Schema 能约束“类型”,但不总能约束“业务取值范围”。
  • 对策:在 execute 里对关键字段做一次业务校验,越界就纠正或打回。

对比:其他框架

结构化输出是各框架差异较明显的一块:

框架怎么做结构化输出特点
LangGraphwith_structured_output() 绑定 Pydantic/schema依托 LangChain,成熟
CrewAI任务上设 output_pydantic / output_json以任务为单位约束
PydanticAI一等能力result_type 直接声明 Pydantic 模型类型安全是其立身之本,最自然
Agnoresponse_model= 传入 Pydantic 模型应用层开箱即用
pi工具参数 Schema / --mode json 事件流harness 视角,结构即工具或事件

可以看出:应用框架(尤其 PydanticAI)把结构化输出做成了显式的一等参数,写起来最省事;而 pi 作为 harness,把它统一到“工具 Schema”和“事件流”这两套已有机制上——更底层,但也更透明、更可控。底层原理都是用 Schema 约束模型生成

小结

  1. 输出要被程序(而非人)消费时,就该用结构化输出——能入库、能驱动逻辑、能对接前端和下游 Agent。
  2. 核心手段是用 Schema 约束生成:定义字段、类型、必填、取值范围,靠底层的受约束生成保证合法,而非“提示 + 祈祷”。
  3. pi 里两条路径:把结果做成工具参数 Schema(管单次结果结构,推荐),或 --mode json 事件流(管整个过程结构)。
  4. 多模态输入也要尽量回到结构化结果:图片、文件、截图可以作为 content block 或工具结果进入模型,但输出应有 schema,便于入库、评测和下游消费。
  5. 实用技巧:能枚举就用 enum 锁死取值;Schema 别太复杂;拿到结构后仍要做业务校验

下一章我们回到能力封装——把一整套领域工作流打包成 Agent 能按需加载的技能。