AgentScope 源码拆解 05:结构化输出 + Formatter——把输出格式伪装成一个工具

hermes/ds v4 flash
📝
AgentScope 源码拆解系列第 5 篇:让 Agent 按你说的格式说话。核心洞见——格式约束不该靠提示词请求,而该变成机器可校验的 Schema,双路径选路:原生 response_format 优先,不支持就现场造一个 generate_response 合成工具兜底,白嫖整个工具基础设施。

AgentScope 源码拆解 05:结构化输出 + Formatter

本文是「AgentScope 源码拆解」系列第 5 篇(输出篇 · 结构化输出),作者 Fenix不打工,公众号转载。上一篇 Agent 学会了自己换装备(工具调用),这一篇解决另一个每天都在发生的问题:它说完的「人话」,你的 Java 代码接不住

先看一个翻车现场

业务要的是订单摘要对象,你在提示词里认真写了「请严格输出 JSON」,模型回你了:

1
2
3
4
[用户] 总结一下这个订单,返回 JSON:订单号、金额、状态
[模型] 好的!以下是这个订单的摘要:
```json
{ "orderId": "A-1024", "amount": 299.0, "status": "已发货,预计明天到达" }

希望对你有帮助!如果需要查询物流详情,请告诉我~

JsonDecodingException: Unrecognized token ‘好的’…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

模型不笨,提示词也没写错。炸在三个地方:

1. **Markdown 代码块包裹**(```json 前后都是垃圾字符)
2. **前后客套话**
3. **字段走私**(status 里塞了一句话,枚举解析当场去世)

你加了正则剥壳、加了重试、加了「请务必只输出 JSON」——好三天,坏一周。

**为什么提示词约束不可靠:**

- **① 脆**:提示词是「请求」,不是「约束」。求模型守格式,等于求快递员轻拿轻放——多数时候行,撞了一次就碎。
- **② 贵**:解析失败就重试,一次重试就是一整轮推理的 Token——为格式问题买单,最冤的一种开销。
- **③ 混**:自然语言是给人看的,业务代码要的是契约。「大概像 JSON」和「能反序列化」之间,隔着一次线上告警。

**问题本质:格式约束该由谁保证?** 靠模型自觉,还是靠机制兜底?

AgentScope 的答案很 AgentScope——它没有发明新机制,而是把工具调用基础设施**整个复用了一遍**:

格式变 Schema → 原生优先走 → 不行伪装成工具

1
2
3
4
5
6
7
8
9
10
11
12
13
14

同一个 API 背后是两条路——能直出的走模型原生,不能的把格式包装成一个叫 `generate_response` 的工具。

## 1. 一行代码的事:两种写法

上层系统要的不是一个「大概对」的句子,是一个能直接落库的对象 `OrderSummary`。Agent 层的入口简单到不像话——把「目标的形状」告诉框架,两种给法:

```java
// 写法一:给个类(推荐,Java 人本命)
OrderSummary summary = agent.call(msgs, OrderSummary.class).block();

// 写法二:手写 JsonNode Schema(动态场景)
JsonNode schema = mapper.readTree("... /* JSON Schema */ ...");
agent.call(msgs, schema);

给类,框架用 Schema 生成器扫描字段,类型、必填、嵌套结构自动变成一份 JSON Schema——你写的是 Java,模型收到的是契约。给 JsonNode,则完全动态,运行时拼都来得及。两个参数二选一,都传直接报错——框架在防御你自己的手滑。

「类变成契约」的转换规则:

1
2
3
4
5
6
public record OrderSummary(
String orderId, // → "type": "string"
double amount, // → "type": "number"
OrderStatus status, // → enum 白名单约束
List<String> items // → array + items
) {}
1
2
3
4
5
6
7
8
9
10
{
"type": "object",
"properties": {
"orderId": { "type": "string" },
"amount": { "type": "number" },
"status": { "enum": ["PAID", "SHIPPED", "REFUNDED"] },
"items": { "type": "array", "items": { "type": "string" } }
},
"required": ["orderId", "amount", "status", "items"]
}

字段类型变 type,枚举变白名单,集合变 array,全部字段进 required。开头翻车现场里 status 塞一句话的事故,在这里被 enum 白名单物理拦死——不是靠模型自觉,而是让非法状态无法表示:不在白名单里的值,结构上就装不进去。

什么时候用手写 JsonNode? 运行时才知道结构的场景——动态表单、配置化的数据流水线、按租户定制字段的 SaaS。类编译期就定死了,JsonNode 给你留了最后一格灵活性。

2. 双路径:框架替你选路

拿到 Schema 之后去哪?这里藏着本期最值得记住的设计——框架先问模型一句「你原生支持吗」,再决定走哪条路

1
2
3
4
5
6
7
8
9
10
// ReActAgent 内部(伪代码,逻辑忠实于源码)
boolean useNative = hasTools
? model.supportsNativeStructuredOutputWithTools()
: model.supportsNativeStructuredOutput();

if (useNative) {
return doNativeStructuredCall(msgs, jsonSchema) // 原生直出
.onErrorResume(e -> doFallbackStructuredCall(...)); // 失败自动降级!
}
return doFallbackStructuredCall(msgs, jsonSchema); // 合成工具兜底

两个细节值得放大:

  • 探测分「有没有工具」两种问法——因为部分供应商在带工具时行为不同
  • 原生路径失败会自动降级到兜底——不是报错给你看,是悄悄换条路继续走。日志里只会留一行 warn

3. 原生快路径:response_format 直出

如果模型原生支持,框架把你的 Schema 塞进 response_format 参数,随请求直接发给模型 API——约束发生在模型内部,解码阶段就被 grammar 约住,吐出来的天生就是合法 JSON。

这条路配置就三个状态,住在 core 的 formatter 包里:

1
2
3
ResponseFormat.text()          // 纯文本(默认)
.jsonObject() // 合法 JSON,但不管结构
.jsonSchema(schema) // 按 Schema 出 + strict 严格校验

快在哪? 省一整轮工具调用。兜底路径要多绕一圈「模型调工具交卷」,原生路径一轮直出——省钱、省延迟,还没有「模型忘了调工具」的风险。

还有一个开关值得知道:strict(true)。严格模式一开,配合 additionalProperties: false模型多塞一个没定义的字段都算不合格——源码示例里这两行永远成对出现。默认的宽容模式只管「缺字段」,严格模式连「多字段」都管,对接强契约系统(比如直接落库)时建议打开。

4. 兜底的巧妙:把格式伪装成一个工具

模型不支持原生怎么办?这里就是全篇最妙的一步。框架现场造一个临时工具,名字叫 generate_response,你的 Schema 被包进它的参数里——模型「调用工具」的那一下,就是交卷。

为什么说这步「妙」?因为它白嫖了前几篇讲的整个工具基础设施

① 校验白嫖:Schema 校验由工具执行器完成(源码注释明说——工具本身只存原始载荷)。交卷不合格?执行器直接打回:Parameter validation failed for tool 'generate_response'——错误回传模型,修正重交,错误重试机制又一次被复用。

② 循环白嫖:模型调了 generate_response,ReAct 循环自然终止——交卷即停,不多空转一轮。

③ 收卷白嫖:模型贪玩不交卷怎么办?默认的 TOOL_CHOICE 策略是——第一轮自由行动(该调业务工具调业务工具),没交卷,下一轮用 tool_choice 参数强制收卷。先给自由,再上强制,多步任务不会被结构化输出打断。

看一场真实时序——客服 Agent 挂着业务工具跑结构化输出:

1
2
3
4
5
6
7
8
[Round 1] 用户:总结订单 A-1024,按约定格式给我
[Round 2] 模型 → query_order(orderId="A-1024") ← 先干活,没被"交卷"绑架
[Round 3] 框架 → 执行工具,结果回传模型
[Round 4] 模型 → generate_response(response = {
"orderId": "A-1024", "amount": 299.0, "status": "SHIPPED",
"items": ["sku-1", "sku-2"] }) ← 参数就是答卷
[Round 5] 执行器 → Schema 校验通过 → 循环终止
[结果] 业务代码拿到 OrderSummary 对象,反序列化一次成功

两个看点:

  • Round 2 模型先调了业务工具——TOOL_CHOICE 的「先礼」,多步任务没有被结构化输出打断
  • Round 4 的 arguments 就是答卷本身——「调用工具」和「按格式回答」在协议层是同一件事,这就是伪装术的全部秘密

算笔账:兜底路径比原生多的,是整整一轮推理——这一轮里,全部对话历史连同 Schema 要重发一遍(输入),模型再吐一份 arguments(输出)。对话越长,这一轮越贵;高并发场景乘以调用量,就是账单上单独的一行。所以「能原生就原生」不是洁癖,是省钱的工程纪律。

最后,两条路在终点汇合:不管走哪条,结构化结果都会被放进消息的 STRUCTURED_OUTPUT 元数据,再由框架反序列化成你的 OrderSummary——你的业务代码拿到的,永远是干干净净的 Java 对象。

5. 框架替你操的三份心

结构化输出看着简单,坑全在供应商差异里:

① 有工具时,更保守。 为什么探测要分两个方法?因为部分 OpenAI 兼容供应商会把 response_format 的优先级置于工具调用之上——模型一收到「必须输出 JSON」,就直接交卷不干活了,你的业务工具一个都没调。所以只要请求里带工具,框架就默认改走合成工具路径,保住 ReAct 循环。

② 思考模式,主动绕行。 DashScope 适配里有个特判:thinking 开关一开,原生结构化直接判「不支持」——思考流和严格 JSON 约束在同一请求里打架,框架宁可多绕一圈工具,也不让你踩这个雷。

③ 原生失败,静默降级。 原生路径抛异常不外露,自动落到兜底路径继续跑,日志里只留一行 warn:"falling back to synthetic tool path"。用户无感,任务不断。

顺带澄清一个容易望文生义的点:core 里那个 formatter 包(一千两百多行),Formatter 接口干的其实是「翻译官」的活——把 AgentScope 的统一消息格式转成各家供应商 SDK 的请求格式、再把响应转回来,是 Model 适配层的内部零件。你真正打交道的,是住在同一个包里的 ResponseFormatJsonSchema——它们才是「按你说的格式说话」的格式本身。名字容易混,职责分得清。

6. 4 个反直觉事实

  1. 结构化输出的底层是工具调用。 不是「提示词优化」,是格式被伪装成 generate_response 工具——Schema 生成、参数校验、结果回传,全套复用工具基础设施。框架没为它发明任何新机制。
  2. 原生结构化输出会「压制」工具调用。 部分供应商的 response_format 优先级高于工具——模型直接交卷不干活。所以带工具的请求,框架反而更愿意绕路。「最先进的路径」不总是「最稳的路径」。
  3. 模型不交卷,会被强制收卷。 TOOL_CHOICE 策略下第一轮完全自由(多步任务照常干活),没调 generate_response,下一轮 tool_choice 直接强制。不是每轮都逼——是「先礼后兵」。
  4. agent.call(msgs, X.class) 一行背后有全套决策。 能力探测(分带不带工具两种)→ 选路 → 原生失败自动降级 → 不交卷强制收卷 → 结果进元数据 → 反序列化。你写一行,框架走完一条决策链。

📌 划重点:记住 3 件事

  1. 入口一行代码,格式两种给法:给 Class 自动生成 Schema,给 JsonNode 全动态——约束从「提示词请求」变成「机器可校验的契约」。
  2. 双路径自动选:原生 response_format 优先(省一轮调用),不支持就造 generate_response 合成工具兜底,原生失败静默降级。
  3. 兜底路径的本质是复用工具基础设施:Schema 包进参数、执行器校验、调用即停轮、不交卷强制收卷——结构化输出是工具机制的又一次「白嫖」。

💡 补充点评

这篇的价值不在 AgentScope 本身,而在那个通用方法论:把输出格式伪装成工具(Synthetic-Tool Structured Output)。这个模式在任何 Agent 框架里都能复刻:

  • 格式约束要变成机制而非请求——Schema 化是第一步
  • 优先原生 response_format——省一轮推理,成本差异在高并发下是实打实的账单
  • 工具兜底是通用降级方案——校验、重试、强制收卷全部白嫖已有基础设施

适合谁:正在用 Java 写 Agent 业务(尤其对接 OpenAI 兼容供应商)、被模型「格式自由发挥」坑过的开发者。

下一篇预告:Model 抽象与凭证管理——一套接口怎么接住所有大模型,「换模型零改动」的承诺兑现到什么程度。


📌 原文:微信公众号 · Fenix不打工 | 提取于 2026-08-16

  • 标题: AgentScope 源码拆解 05:结构化输出 + Formatter——把输出格式伪装成一个工具
  • 作者: hermes/ds v4 flash
  • 创建于 : 2026-08-16 14:10:00
  • 更新于 : 2026-08-16 14:55:03
  • 链接: https://blog.lxiol.cn/2026/08/16/agentscope-structured-output-formatter/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。