Agent 工程体系 · 第 8/98 篇。内容以 2026 年 9 月可验证的公开规范和稳定接口为基线;框架版本敏感能力会明确标注,不把实验行为写成通用保证。
Agent 结构化输出:JSON Schema、严格解析、修复和版本兼容
Agent 不只是“让模型返回一段 JSON”。一个可运行的 Agent 系统需要回答四个不同的问题:
- 模型是否理解了输出结构?
- 返回文本是否是合法 JSON?
- 合法 JSON 是否满足约定的 JSON Schema?
- 这个结构在当前版本、旧版本和未来版本之间是否仍然可以被正确处理?
这四个问题分别对应生成约束、语法解析、语义校验和契约兼容。如果把它们混成一个“解析 JSON”的步骤,系统就会在看似正常的情况下产生难以诊断的错误:字段缺失、类型漂移、枚举失效、工具参数误执行、旧客户端崩溃,甚至同一个请求被重复执行。
本文讨论 Agent 中结构化输出的完整链路,并区分三类经常被混淆的机制:
- LLM 结构化输出:约束模型生成符合 Schema 的数据;
- 工具调用参数:模型生成供工具执行的参数;
- 工具结果结构化输出:工具执行后返回符合 Schema 的结果。
在 MCP 中,工具通过 inputSchema 描述输入,通过可选的 outputSchema 描述结构化结果;其中 structuredContent 是服务端产生的工具结果数据,不等同于模型的 structured outputs。(modelcontextprotocol.io)
一、先建立边界:结构化输出不是一个概念
1. JSON 是数据格式,不是业务契约
JSON 是一种数据表示格式。下面的文本是合法 JSON:
{
"city": "杭州",
"temperature": 28
}
但“合法 JSON”只说明它符合 JSON 的语法,不说明:
temperature是否必须是数字;- 是否允许缺少
city; - 是否允许额外字段;
city是否必须来自某个枚举;temperature的单位是什么;- 这个对象代表天气查询结果,还是用户资料。
因此,JSON 解析只能回答:
它不能回答:
2. JSON Schema 是可执行的结构契约
JSON Schema 是描述 JSON 值约束的一种声明式语言。它可以定义:
- JSON 类型:
object、array、string、number、integer、boolean、null; - 对象属性:
properties; - 必填属性:
required; - 额外属性:
additionalProperties; - 枚举:
enum; - 数值和字符串边界;
- 嵌套对象和数组;
- 条件约束、组合约束和引用。
例如:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"properties": {
"city": {
"type": "string",
"minLength": 1,
"description": "城市名称,例如杭州"
},
"units": {
"type": ["string", "null"],
"enum": ["celsius", "fahrenheit", null],
"description": "温度单位;未指定时为 null"
}
},
"required": ["city", "units"]
}
这个 Schema 表示:
- 根值必须是对象;
- 对象只能包含
city和units; city必须是非空字符串;units必须是"celsius"、"fahrenheit"或null;- 两个字段都必须出现。
这里有一个重要区别:
{
"type": "string"
}
表示字段值必须是字符串;而:
{
"type": ["string", "null"]
}
表示字段值可以是字符串,也可以是 null。
允许为空不等于字段可省略。如果字段仍出现在 required 中,它必须存在,只是值可以为 null。这在严格结构化输出中非常重要,因为某些严格模式要求对象中的属性全部列入 required,可选语义通常通过 ["类型", "null"] 表示。OpenAI 的函数调用严格模式要求对象设置 additionalProperties: false,并要求 properties 中的字段全部标记为 required;不满足条件时,严格请求可能被拒绝。(developers.openai.com)
二、Agent 结构化输出的四层验证
一个可靠的解析器不应只有 json.loads()。至少要区分以下四层:
模型输出
│
├─ 1. 通道识别:文本、工具调用、拒答、错误、事件?
│
├─ 2. JSON 语法解析:能否得到 JSON value?
│
├─ 3. JSON Schema 校验:结构和类型是否满足契约?
│
├─ 4. 业务校验:值是否有实际意义?
│
└─ 5. 版本归一化:是否能转换为当前内部模型?
可以形式化为:
其中:
- :通道识别,判断输出类型;
- :JSON 语法解析;
- :JSON Schema 校验;
- :版本归一化;
- :应用层最终接受的结构。
只有当所有步骤成功时,结果才可以进入业务流程。
1. 通道识别
模型返回的内容可能是:
- 普通文本;
- JSON 文本;
- 工具调用;
- 多个并行工具调用;
- 拒答;
- API 错误;
- 流式事件中的部分 JSON;
- MCP 工具结果;
- 需要用户补充输入的中间状态。
因此,不能直接把整个 API 响应当作业务 JSON。
例如,工具调用的抽象结果可能是:
{
"type": "function_call",
"name": "get_weather",
"arguments": "{\"city\":\"杭州\",\"units\":\"celsius\"}"
}
这里的 arguments 是一个字符串形式的 JSON,而不是已经解析好的对象。解析流程是:
API 响应
└─ function_call
└─ arguments 字符串
└─ JSON parse
└─ Schema validate
└─ 权限和业务校验
└─ 执行工具
若 Agent 直接执行 arguments 中的字段,而不校验工具名称、Schema、权限和幂等键,结构化输出就会变成危险的“未经验证的命令”。
2. 语法解析
语法解析失败意味着输出不是合法 JSON。例如:
{"city": "杭州",}
尾部逗号在 Python 字典中合法,但在标准 JSON 中不合法。
另一个常见例子是 Markdown 代码围栏:
```json
{"city": "杭州"}
```
这段整体不是 JSON。代码围栏是展示格式,不属于 JSON 数据本身。
3. Schema 校验
下面三个值都是合法 JSON:
{"city": "杭州"}
{"city": 123}
{"city": "杭州", "debug": true}
但在前面的严格 Schema 下:
- 第一个缺少
units; - 第二个
city类型错误; - 第三个包含未声明的
debug,会因additionalProperties: false被拒绝。
4. 业务校验
Schema 校验通过,不代表业务可以执行。
例如:
{
"account_id": "acct_123",
"amount": 0.01,
"currency": "CNY"
}
它可能完全符合 Schema,但业务规则仍可能拒绝:
- 账户不存在;
- 当前用户无权访问;
- 金额低于支付系统最小单位;
- 货币与账户不匹配;
- 请求已处理过;
- 当前操作需要人工确认。
因此:
Schema 主要保证数据形状,业务校验保证数据在当前状态下可执行。
三、严格输出的真正含义
1. 严格解析与严格生成不是一回事
“严格”至少有三种含义:
严格生成
由模型服务端根据 Schema 约束生成结果,使模型输出更可靠地符合结构。
严格解析
客户端收到结果后,使用 JSON 解析器和 Schema 校验器验证,不接受模糊转换。
严格执行
只有在通道、Schema、权限、业务状态和幂等性都确认后,才执行外部副作用。
三者不能互相替代:
严格生成 ≠ 严格解析
严格解析 ≠ 严格执行
严格生成 + 严格解析 ≠ 自动安全
即使服务端声称输出遵循 Schema,客户端仍应进行独立校验。原因包括:
- Schema 可能在代理层被修改;
- 不同模型或 API 版本的支持范围不同;
- 流式拼接可能被截断;
- 中间件可能改变字段;
- 工具调用可能来自旧版本缓存;
- 结构合法但权限不合法。
2. OpenAI 函数调用中的严格模式
OpenAI 的函数调用使用 JSON Schema 描述工具参数。严格模式用于提高函数参数遵循 Schema 的可靠性,官方文档建议通常启用 strict。严格模式要求对象的 additionalProperties 为 false,并要求所有 properties 中的字段出现在 required 中。(developers.openai.com)
典型工具定义可以写成:
{
"type": "function",
"name": "get_weather",
"description": "查询指定城市的当前天气。只用于读取天气,不执行其他操作。",
"strict": true,
"parameters": {
"type": "object",
"additionalProperties": false,
"properties": {
"city": {
"type": "string",
"description": "城市名称,例如杭州或 Paris"
},
"units": {
"type": ["string", "null"],
"enum": ["celsius", "fahrenheit", null],
"description": "温度单位;用户未指定时传 null"
}
},
"required": ["city", "units"]
}
}
这里 units 不是传统意义上的可选字段。它始终出现,只是可以为 null。这样做的好处是调用方不需要在多个缺失状态之间猜测:
缺失 units
units = null
units = ""
units = "default"
严格契约把这些状态压缩为明确的两种语义:
units = "celsius" | "fahrenheit" | null
3. additionalProperties: false 的代价和价值
设置:
"additionalProperties": false
意味着调用方拒绝未声明字段。这可以防止:
{
"city": "杭州",
"units": "celsius",
"execute_shell": "rm -rf /"
}
中的未知字段被意外传入下游。
但它也会降低前向兼容性。假设服务端将来增加:
{
"city": "杭州",
"units": "celsius",
"timezone": "Asia/Shanghai"
}
旧客户端若使用 additionalProperties: false,就会拒绝新对象。
因此,严格 Schema 更适合作为边界契约,而不是直接作为所有内部对象的永久存储格式。常见做法是:
外部严格 Schema
→ 版本适配器
→ 当前内部领域对象
外部输入拒绝未知字段,内部迁移逻辑则显式处理版本差异。
四、工具调用和结构化结果的完整数据流
Agent 工具调用通常不是一次请求一次响应,而是一个循环:
sequenceDiagram
participant U as 用户
participant O as Agent Orchestrator
participant M as 模型
participant V as 校验器
participant T as 工具
participant D as 外部系统
U->>O: 用户请求
O->>M: 输入 + 工具定义 + Schema
M-->>O: 文本或工具调用
O->>V: 解析工具名和 arguments
V-->>O: 通过 / 拒绝
O->>O: 权限、审批、幂等键检查
O->>T: 执行工具
T->>D: 调用数据库或外部 API
D-->>T: 外部结果
T-->>O: 工具结果 + 可选结构化数据
O->>V: 校验工具输出
V-->>O: 通过 / 拒绝
O->>M: 工具结果
M-->>O: 最终文本或下一次工具调用
O-->>U: 最终响应
OpenAI 函数调用的基本流程是:应用把工具定义提供给模型,模型产生工具调用,应用执行对应函数,再把工具输出提交回模型,模型继续生成最终响应或下一轮工具调用。(developers.openai.com)
MCP 则将工具发现和调用标准化为 JSON-RPC 消息。客户端可以通过 tools/list 获取工具及其 inputSchema,再通过 tools/call 携带工具名和参数执行;MCP 工具结果还可以携带符合 outputSchema 的 structuredContent。(modelcontextprotocol.io)
这两者的层次不同:
| 层次 | 作用 | 典型字段 |
|---|---|---|
| 模型工具选择 | 模型决定调用哪个工具 | name |
| 工具输入契约 | 约束模型生成的参数 | parameters、inputSchema |
| 工具执行协议 | 客户端调用服务端工具 | tools/call |
| 工具输出契约 | 约束工具返回的数据 | outputSchema、structuredContent |
| 最终回答结构 | 约束 Agent 面向用户的结果 | response schema |
不要因为工具输入已经有 Schema,就认为工具输出也自动有 Schema。输入和输出是两个方向的契约,必须分别定义和验证。
五、JSON Schema 的设计:把不确定性显式化
1. 字段是否存在,必须有唯一语义
下面四种表示经常被错误混用:
{}
{"value": null}
{"value": ""}
{"value": 0}
它们分别可能表示:
- 未提供;
- 明确没有值;
- 空字符串;
- 数值为零。
如果业务上这些状态不同,就必须在 Schema 和领域模型中保留差异。
例如,一个订单查询工具:
{
"type": "object",
"additionalProperties": false,
"properties": {
"order_id": {
"type": ["string", "null"],
"description": "订单号;如果用户没有提供则为 null"
},
"email": {
"type": ["string", "null"],
"description": "邮箱;如果用户没有提供则为 null"
}
},
"required": ["order_id", "email"]
}
这个 Schema 仍然允许两者同时为 null。如果业务要求至少提供一个,就需要额外的业务校验,或者使用 JSON Schema 的组合约束:
{
"type": "object",
"additionalProperties": false,
"properties": {
"order_id": {
"type": ["string", "null"]
},
"email": {
"type": ["string", "null"]
}
},
"required": ["order_id", "email"],
"anyOf": [
{
"required": ["order_id"],
"properties": {
"order_id": {
"type": "string",
"minLength": 1
}
}
},
{
"required": ["email"],
"properties": {
"email": {
"type": "string",
"minLength": 1
}
}
}
]
}
不过,在 Agent 场景中,过于复杂的 Schema 可能增加模型理解和服务端兼容成本。若条件约束难以稳定表达,可以采用:
结构 Schema:表达字段形状
业务校验:表达跨字段规则
这不是降低严格性,而是把不同类型的约束放在合适的层。
2. 枚举用于控制离散状态
错误的做法:
{
"status": {
"type": "string",
"description": "状态,可以是 pending、paid 或 cancelled"
}
}
描述中写了枚举,但 Schema 没有真正限制值。模型仍可能返回:
{"status": "完成"}
更可靠的写法是:
{
"status": {
"type": "string",
"enum": ["pending", "paid", "cancelled"],
"description": "订单状态。必须使用英文枚举值。"
}
}
description 解释语义,enum 执行约束,两者不能互相替代。
枚举值应当优先使用稳定、机器可读、与展示语言无关的标识:
pending
paid
cancelled
而不是:
待支付
已支付
已取消
展示层可以把稳定枚举映射为中文。这样可以避免多语言、拼写和文案修改影响契约。
3. 数值范围不能只依赖描述
错误:
{
"quantity": {
"type": "integer",
"description": "购买数量,必须大于 0"
}
}
更明确:
{
"quantity": {
"type": "integer",
"minimum": 1,
"maximum": 100
}
}
但即使 Schema 设置了 minimum,业务层仍可能需要检查库存、用户限购和活动规则。Schema 约束的是静态形状,库存是动态状态。
4. 时间、金额和标识符要避免隐式类型
推荐:
{
"created_at": {
"type": "string",
"format": "date-time"
},
"amount_minor": {
"type": "integer",
"minimum": 0,
"description": "以最小货币单位表示,例如人民币分"
},
"currency": {
"type": "string",
"enum": ["CNY", "USD"]
}
}
不推荐:
{
"created_at": 1750000000,
"amount": 19.9
}
Unix 时间戳和浮点金额都需要额外的单位和精度约定。特别是金额,二进制浮点数可能产生:
在支付和结算场景中,应使用最小货币单位的整数,或使用明确的十进制定点类型。
六、严格解析:从字符串到可信对象
下面给出一个不依赖特定模型厂商 SDK 的 Python 解析器。它分离了:
- JSON 语法解析;
- Schema 校验;
- 业务校验;
- 版本归一化。
先安装 Schema 校验库:
python -m pip install jsonschema
示例代码:
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any
from jsonschema import Draft202012Validator
SCHEMA_V1 = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": False,
"properties": {
"city": {
"type": "string",
"minLength": 1,
},
"units": {
"type": ["string", "null"],
"enum": ["celsius", "fahrenheit", None],
},
},
"required": ["city", "units"],
}
class OutputError(Exception):
"""结构化输出无法被安全接受。"""
@dataclass(frozen=True)
class WeatherRequest:
city: str
units: str | None
def parse_json_strict(raw: str) -> Any:
"""
只负责 JSON 语法解析,不负责业务判断。
"""
try:
value = json.loads(raw)
except json.JSONDecodeError as exc:
raise OutputError(
f"invalid_json: line={exc.lineno}, column={exc.colno}, msg={exc.msg}"
) from exc
# 这里要求根值必须是对象,避免后续代码把数组或字符串当成请求对象。
if not isinstance(value, dict):
raise OutputError("root_must_be_object")
return value
def validate_schema(value: Any, schema: dict[str, Any]) -> None:
"""
只负责 JSON Schema 校验。
"""
validator = Draft202012Validator(schema)
errors = sorted(validator.iter_errors(value), key=lambda error: list(error.path))
if errors:
details = []
for error in errors:
path = ".".join(str(part) for part in error.path) or "$"
details.append({
"path": path,
"keyword": error.validator,
"message": error.message,
})
raise OutputError(f"schema_validation_failed: {details}")
def validate_business(value: dict[str, Any]) -> None:
"""
Schema 通过后,执行动态业务规则。
"""
city = value["city"].strip()
if not city:
raise OutputError("city_must_not_be_blank")
# 示例业务规则:限制查询范围,真实系统应从授权数据源判断。
if len(city) > 100:
raise OutputError("city_name_too_long")
def normalize_v1(value: dict[str, Any]) -> WeatherRequest:
"""
将外部契约转换为内部类型。
"""
return WeatherRequest(
city=value["city"].strip(),
units=value["units"],
)
def parse_weather_request(raw: str) -> WeatherRequest:
value = parse_json_strict(raw)
validate_schema(value, SCHEMA_V1)
validate_business(value)
return normalize_v1(value)
if __name__ == "__main__":
request = parse_weather_request(
'{"city": " 杭州 ", "units": "celsius"}'
)
print(request)
预期输出:
WeatherRequest(city='杭州', units='celsius')
每一步的成立条件不同:
json.loads()成功,只说明文本语法正确;Draft202012Validator成功,说明对象满足 Schema;validate_business()成功,说明当前业务规则允许;normalize_v1()成功,说明外部 JSON 已转换为内部类型。
下面的输入会分别失败:
parse_weather_request('{"city": "杭州",}')
# invalid_json
parse_weather_request('{"city": "杭州"}')
# schema_validation_failed: 缺少 units
parse_weather_request('{"city": 123, "units": "celsius"}')
# schema_validation_failed: city 不是 string
parse_weather_request(
'{"city": "杭州", "units": "celsius", "debug": true}'
)
# schema_validation_failed: 不允许额外属性 debug
注意:错误信息可以记录到日志,但不应把完整原始输出直接回传给最终用户,尤其当输出包含敏感上下文时。
七、修复:什么时候可以做,什么时候不能做
1. 修复不是“尽量猜出一个结果”
修复是把可诊断、低风险、语义明确的格式错误转换为合法输入。例如:
- 去掉包围 JSON 的 Markdown 代码围栏;
- 去掉输出前后的空白;
- 从明确的单一 JSON 文本中提取对象;
- 在流式传输结束后拼接完整片段。
修复不能改变不确定的业务含义。例如:
{"amount": "100"}
是否可以转成:
{"amount": 100}
取决于:
- Schema 是否允许数字字符串;
- 金额是否允许小数;
"100"是人民币元还是分;- 下游是否接受自动转换。
如果无法从契约明确推导,就不应自动修复。
2. 修复的安全顺序
推荐顺序如下:
原始响应
→ 限制大小
→ 识别通道
→ 去除明确的展示包装
→ JSON parse
→ Schema validate
→ 业务 validate
不要先使用宽松解析器把所有类似 JSON 的内容“猜成对象”,再进行校验。因为宽松解析可能隐藏模型真正的错误。
一个有限的代码围栏修复器:
import re
FENCED_JSON = re.compile(
r"^\s*```(?:json)?\s*(?P<body>.*?)\s*```\s*$",
re.IGNORECASE | re.DOTALL,
)
def unwrap_json_fence(raw: str) -> tuple[str, bool]:
match = FENCED_JSON.match(raw)
if not match:
return raw, False
return match.group("body"), True
使用时应记录修复动作:
raw = '```json\n{"city":"杭州","units":"celsius"}\n```'
cleaned, repaired = unwrap_json_fence(raw)
if repaired:
print("warning: model_output_wrapped_in_markdown")
value = parse_json_strict(cleaned)
这里的修复是安全的,因为它只移除展示层包装,不改变 JSON 内部字段和值。
3. 不应自动修复的例子
缺少逗号
{"city":"杭州" "units":"celsius"}
无法确定缺少逗号,还是字符串本身被截断。自动插入逗号可能得到错误业务数据。
重复键
{
"amount": 10,
"amount": 100
}
不同解析器可能保留第一个值或最后一个值。严格系统应拒绝重复键,而不是依赖实现细节。
类型猜测
{
"is_admin": "false"
}
将字符串 "false" 转为布尔值 false 看似方便,但模型可能把 "False"、"no"、"0" 都当作同一语义。权限字段尤其不应做隐式转换。
缺字段补默认值
{
"city": "杭州"
}
如果 units 缺失,不能简单补成 "celsius",除非契约明确规定缺失即为摄氏度。否则,缺失和用户明确指定摄氏度不是同一个状态。
4. 修复预算
修复应受到限制:
- 最大输入字节数;
- 最大嵌套深度;
- 最大重试次数;
- 最大总耗时;
- 单次响应最多执行一次修复;
- 修复后必须重新进行完整解析和校验。
修复后的结果不能绕过 Schema 校验:
修复成功 ≠ 校验成功
八、失败重试:把错误反馈给模型,但不要把执行权交出去
当模型输出无法解析时,可以让模型根据机器可读错误重新生成。但重试必须是受控状态机,而不是无限循环。
stateDiagram-v2
[*] --> GENERATED
GENERATED --> PARSED: JSON parse 成功
GENERATED --> REPAIRABLE: 可安全修复
GENERATED --> RETRYABLE: 语法或 Schema 错误
GENERATED --> TERMINAL: 拒答、超限或协议错误
REPAIRABLE --> PARSED: 修复后重新解析
REPAIRABLE --> RETRYABLE: 修复失败
PARSED --> VALIDATED: Schema 通过
PARSED --> RETRYABLE: Schema 失败
RETRYABLE --> GENERATED: 未超过重试预算
RETRYABLE --> TERMINAL: 超过预算
VALIDATED --> BUSINESS_CHECK
BUSINESS_CHECK --> EXECUTABLE: 权限和状态通过
BUSINESS_CHECK --> TERMINAL: 业务拒绝
EXECUTABLE --> EXECUTED: 幂等检查通过
EXECUTABLE --> TERMINAL: 幂等冲突或需审批
EXECUTED --> [*]
TERMINAL --> [*]
重试提示不应只写:
请重新输出正确 JSON。
而应发送结构化错误:
{
"error_type": "schema_validation_failed",
"schema_version": "weather.request.v1",
"violations": [
{
"path": "$.units",
"keyword": "enum",
"message": "must be one of: celsius, fahrenheit, null"
}
],
"instruction": "只返回 JSON 对象,不要使用 Markdown 代码围栏。"
}
这种反馈具有三个优点:
- 模型知道失败位置;
- 模型知道失败约束;
- 系统可以对错误分类和统计。
但不要把内部校验器的全部错误原样暴露给模型。例如路径中可能包含内部表名、权限信息或敏感数据。应先做错误脱敏和归类。
重试不应重新执行副作用
错误发生在模型生成阶段时,可以重试生成;错误发生在工具执行阶段时,不能简单把同一个工具调用再执行一次。
例如:
模型生成支付请求
→ Schema 通过
→ 支付服务超时
此时不应直接再次创建支付。必须使用幂等键:
{
"payment_id": "pay_123",
"amount_minor": 1000,
"currency": "CNY",
"idempotency_key": "agent-run-abc-step-7"
}
执行层需要保证:
这里:
- 是幂等键;
- 是请求参数;
- “请求等价”至少应包括工具名、版本和规范化后的参数。
九、并发与严格输出:多个工具调用不是一个对象
在支持并行工具调用的模型中,一次响应可能包含多个工具调用。OpenAI 文档说明,模型可以在一个轮次中调用多个函数;设置 parallel_tool_calls: false 可以限制为零个或一个工具调用。(developers.openai.com)
因此,以下两种结构完全不同:
{
"name": "get_weather",
"arguments": {
"city": "杭州"
}
}
[
{
"name": "get_weather",
"arguments": {
"city": "杭州"
}
},
{
"name": "get_weather",
"arguments": {
"city": "上海"
}
}
]
并行调用带来两个额外问题。
1. 调用之间是否独立
只有当工具调用满足:
才适合真正并行。
例如:
查询杭州天气
查询上海天气
通常可以并行。
但:
创建订单
扣减库存
通常存在顺序和事务依赖,不能因为模型一次返回两个调用就并行执行。
2. 结果如何关联
每个调用都需要独立标识:
[
{
"call_id": "call_1",
"name": "get_weather",
"arguments": {
"city": "杭州",
"units": "celsius"
}
},
{
"call_id": "call_2",
"name": "get_weather",
"arguments": {
"city": "上海",
"units": "celsius"
}
}
]
工具结果必须按 call_id 回传,不能只按数组位置关联。并发完成顺序可能与发起顺序不同:
call_2 先完成
call_1 后完成
如果系统按完成顺序覆盖结果,就可能把上海天气写入杭州请求。
3. 并发失败不是整体失败
对于并行调用:
call_1 成功
call_2 超时
call_3 权限拒绝
系统应分别记录状态,而不是把所有结果压成一个字符串:
{
"results": [
{
"call_id": "call_1",
"status": "succeeded",
"value": {}
},
{
"call_id": "call_2",
"status": "timeout",
"retryable": true
},
{
"call_id": "call_3",
"status": "forbidden",
"retryable": false
}
]
}
如果需要将这些结果再次交给模型,也应保留每个调用的身份、状态和错误类别。
十、MCP 中的输入 Schema、输出 Schema 和结构化结果
MCP 工具定义包含工具名、描述和 inputSchema;inputSchema 必须是合法 JSON Schema 对象,没有参数的工具也应使用明确的对象 Schema。工具可以额外提供 outputSchema,用于描述结构化结果。(modelcontextprotocol.io)
一个工具定义可以抽象为:
{
"name": "get_weather",
"description": "获取指定城市的当前天气",
"inputSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"city": {
"type": "string",
"minLength": 1
},
"units": {
"type": ["string", "null"],
"enum": ["celsius", "fahrenheit", null]
}
},
"required": ["city", "units"]
},
"outputSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"city": {
"type": "string"
},
"temperature": {
"type": "number"
},
"units": {
"type": "string",
"enum": ["celsius", "fahrenheit"]
},
"observed_at": {
"type": "string",
"format": "date-time"
}
},
"required": ["city", "temperature", "units", "observed_at"]
}
}
调用后,服务端可以返回:
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"structuredContent": {
"city": "杭州",
"temperature": 28.0,
"units": "celsius",
"observed_at": "2026-09-01T10:00:00Z"
},
"content": [
{
"type": "text",
"text": "{\"city\":\"杭州\",\"temperature\":28.0,\"units\":\"celsius\",\"observed_at\":\"2026-09-01T10:00:00Z\"}"
}
],
"isError": false
}
}
MCP 规范明确区分:
structuredContent:服务端产生的 JSON 值;content:可以包含文本、图片、音频、资源链接等内容;outputSchema:用于校验结构化结果。
如果提供了 outputSchema,服务端必须返回符合该 Schema 的结构化结果;为了向后兼容,返回结构化内容的工具还应将序列化 JSON 放入文本内容中。(modelcontextprotocol.io)
因此,MCP 工具结果处理应当是:
读取 result
├─ resultType 是否允许?
├─ isError 是否为 true?
├─ structuredContent 是否存在?
├─ structuredContent 是否符合 outputSchema?
└─ content 是否只作为展示或兼容回退?
不要把 content[0].text 当作唯一真相。如果服务端提供了 structuredContent,优先使用结构化字段,并对文本字段进行一致性检查或仅用于兼容旧客户端。
十一、错误结果、协议错误和业务错误要分层
一个 Agent 系统至少需要区分三类错误。
1. 协议错误
协议错误表示请求没有按协议正确表达,例如:
- JSON-RPC 消息格式错误;
- 方法不存在;
- 参数结构不合法;
- 协议版本不支持。
MCP 使用 JSON-RPC 2.0 消息在 Host、Client 和 Server 之间通信。(modelcontextprotocol.io)
2. 工具执行错误
工具本身被正确调用,但执行失败:
{
"result": {
"content": [
{
"type": "text",
"text": "数据库连接超时"
}
],
"isError": true
}
}
这不是模型输出解析失败,而是工具执行状态。编排器应根据错误类型决定:
- 是否重试;
- 是否更换工具;
- 是否请求用户补充信息;
- 是否终止 Agent。
3. 业务拒绝
工具执行成功,但业务不允许:
{
"status": "rejected",
"reason_code": "INSUFFICIENT_BALANCE"
}
这不应被包装成系统异常。业务拒绝通常是可预期结果,应进入正常业务分支。
4. MCP 的输入补充状态
MCP 工具可以返回 input_required,要求客户端通过多轮交互获取额外输入,例如请求用户提供 GitHub 用户名。重试时需要携带输入响应,并且 JSON-RPC 的 id 必须与初始请求不同。(modelcontextprotocol.io)
这说明“工具调用失败”不一定是错误终态:
工具调用
→ input_required
→ 向用户请求信息
→ 带 inputResponses 重试
如果编排器把所有非成功结果都当作失败并重试原始参数,就会形成死循环。
十二、版本兼容:Schema 的改变就是 API 改变
1. 版本兼容的三个方向
假设当前版本为:
weather.request.v1
出现新版本时,需要分别考虑:
- 向后兼容:新服务端能否处理旧客户端的输入;
- 向前兼容:旧客户端能否处理新服务端的输出;
- 跨版本转换:是否存在明确的适配器。
一个简单的兼容模型是:
但实际系统还要考虑输出消费方,因此输入和输出必须分别评估。
2. 常见兼容变化
通常较安全的变化
在宽松读取端,新增可选字段通常较安全:
{
"city": "杭州",
"units": "celsius",
"timezone": "Asia/Shanghai"
}
前提是旧客户端会忽略未知字段。
可能破坏兼容的变化
- 删除已有字段;
- 修改字段类型;
- 缩小枚举范围;
- 把必填字段改成不同语义;
- 修改单位;
- 把金额从元改为分但不改字段名;
- 把字符串 ID 改成数字;
- 把单个对象改成数组;
- 把
null从允许值中删除。
例如:
"amount": {
"type": "number"
}
改为:
"amount": {
"type": "integer"
}
这不仅是类型改变,还可能意味着精度和单位改变。
3. 不要让版本只存在于文件名
以下方式不够可靠:
weather_schema_final.json
weather_schema_final_v2.json
weather_schema_final_new.json
版本必须进入可观察的协议数据:
{
"schema_id": "weather.request",
"schema_version": "1.2.0",
"payload": {
"city": "杭州",
"units": "celsius"
}
}
或者通过工具、接口和事件元数据表达:
{
"tool": "get_weather",
"tool_version": "2",
"arguments": {
"city": "杭州",
"units": "celsius"
}
}
版本字段本身也必须有稳定语义。不要一部分系统使用:
v1
另一部分使用:
1.0
还有一部分使用:
2026-09-01
日期版本、语义版本和协议修订号可以并存,但必须明确它们分别控制什么。
十三、MCP 的版本协商与兼容策略
MCP 的 2026-07-28 规范将现代版本定义为通过每个请求的元数据传递版本、身份和能力;较早版本使用 initialize 握手。现代请求声明所使用的协议版本,服务端不支持时必须返回 UnsupportedProtocolVersionError,并列出支持的版本;客户端应选择双方支持的版本后重试。(modelcontextprotocol.io)
兼容流程可以写成:
客户端选择首选协议版本
│
├─ 服务端接受
│ └─ 按该版本处理
│
├─ 服务端返回 UnsupportedProtocolVersionError
│ ├─ 读取 supported
│ ├─ 选择共同版本
│ └─ 使用新请求 ID 重试
│
└─ 无共同版本
└─ 返回明确的兼容性错误
MCP 规范还允许客户端在必要时兼容现代和旧式服务端。对于 stdio 和 HTTP,识别旧服务端的方式不同;客户端应缓存服务端所处时代,但如果后续假设失效,则需要重新探测。(modelcontextprotocol.io)
这带来一个工程上的区分:
协议版本兼容
工具 Schema 兼容
业务语义兼容
即使 MCP 协议版本兼容,也不代表某个工具的 inputSchema 或 outputSchema 兼容。协议负责“消息如何传输”,工具 Schema 负责“数据如何解释”。
十四、工具发现也会影响结构化输出的稳定性
工具名称和描述不是装饰性元数据,它们决定模型能否发现并正确选择工具。
MCP 要求工具通过 tools/list 暴露,工具名称应唯一、可区分,并建议使用字母、数字、下划线、连字符或点号。聚合多个服务端工具时,客户端可能遇到同名工具,应采用前缀或其他消歧策略。(modelcontextprotocol.io)
例如:
search
在多个服务端聚合后可能变成:
crm.search
docs.search
web.search
如果不做消歧,模型即使生成了合法的参数,也可能调用错误的工具。
工具描述应同时说明:
- 工具做什么;
- 不做什么;
- 参数的单位;
- 返回值的语义;
- 是否有副作用;
- 是否需要用户确认;
- 失败时的行为。
例如:
{
"name": "refund_order",
"description": "为已支付且未完成退款的订单发起退款。该操作具有资金副作用;不会自动退款已完成或已关闭订单,执行前需要用户确认。",
"inputSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"order_id": {
"type": "string",
"minLength": 1
},
"confirmation_token": {
"type": "string",
"minLength": 1
}
},
"required": ["order_id", "confirmation_token"]
}
}
Schema 只能约束 order_id 和 confirmation_token 的形状,无法证明调用者真的获得了用户确认。确认状态必须由编排器或授权系统验证。
十五、严格输出与拒答、截断、流式响应
1. 拒答不是 JSON 校验失败
如果模型因为安全策略拒绝回答,系统收到的可能不是目标 Schema 的实例。此时不能把拒答当作“模型格式错误”并不断重试。
应当先识别结果状态:
正常结构化结果
拒答
达到输出限制
响应截断
工具调用
服务端错误
只有“预期通道中的结构化内容”才进入 JSON 解析。
2. 截断的 JSON 不能修复成成功
例如流式结果只收到:
{
"city": "杭州",
"temperature":
这通常表示输出被截断,而不是普通语法错误。若继续等待流结束后仍不完整,应记录:
termination_reason = incomplete
不要通过补 0、补 null 或闭合括号把它伪装成成功结果。
3. 流式 JSON 必须按事件边界处理
流式传输中的片段可能是:
{"ci
ty":"杭州"}
单个片段不是 JSON,只有拼接完整后才可以解析:
chunk_1 + chunk_2 + ... + chunk_n
→ 完整字符串
→ JSON parse
因此,流式 UI 可以逐片段展示文本,但不能在没有明确增量协议的情况下逐片段执行工具参数。
特别危险的做法是:
收到 {"path": "/tmp/a"}
→ 立即开始文件操作
收到后续 {"mode": "delete"}
→ 改变执行语义
工具执行必须等待完整调用、完整解析和完整校验。
十六、生产诊断:记录“哪一层失败”
只记录“Agent 失败”无法定位问题。建议把失败阶段作为一等字段:
{
"trace_id": "trace_abc",
"run_id": "run_123",
"step_id": "step_7",
"tool_name": "get_weather",
"schema_version": "weather.request.v1",
"phase": "schema_validation",
"status": "rejected",
"error_code": "additional_property",
"path": "$.debug",
"retry_count": 1,
"repaired": false
}
推荐的阶段枚举:
response_classification
json_parse
schema_validation
business_validation
authorization
approval
idempotency
tool_execution
tool_output_validation
protocol_negotiation
每一类失败都对应不同处理:
| 阶段 | 典型原因 | 是否适合重试 |
|---|---|---|
| JSON parse | 截断、代码围栏、非法逗号 | 有限重试或有限修复 |
| Schema validation | 缺字段、类型错误、额外字段 | 有限重试 |
| Business validation | 订单不存在、状态不允许 | 通常不重试原请求 |
| Authorization | 无权限 | 不应盲目重试 |
| Idempotency | 重复或请求冲突 | 查询既有结果或终止 |
| Tool execution | 超时、网络错误 | 取决于副作用和幂等性 |
| Tool output validation | 工具返回错误结构 | 修复工具或降级,不应让模型猜 |
| Protocol negotiation | 版本不支持 | 选择共同版本后重试 |
日志中可以保留经过脱敏和截断的原始输出摘要,但不要默认保存完整的提示词、附件、令牌和个人信息。
十七、一个可维护的契约目录
一个 Agent 项目可以按以下方式组织契约:
contracts/
├── weather/
│ ├── request.v1.schema.json
│ ├── response.v1.schema.json
│ ├── request.v2.schema.json
│ ├── response.v2.schema.json
│ ├── adapter_v1_to_internal.py
│ └── changelog.md
├── payment/
│ ├── create_payment.request.v1.schema.json
│ └── create_payment.response.v1.schema.json
└── common/
├── error.v1.schema.json
└── pagination.v1.schema.json
每个版本应有:
- Schema 文件;
- 示例有效值;
- 示例无效值;
- 兼容性说明;
- 迁移函数;
- 测试;
- 变更记录。
测试不能只测试“模型常见输出”,还需要测试边界:
VALID_CASES = [
'{"city":"杭州","units":"celsius"}',
'{"city":"Paris","units":"fahrenheit"}',
'{"city":"杭州","units":null}',
]
INVALID_CASES = [
'{}',
'{"city":123,"units":"celsius"}',
'{"city":"杭州","units":"kelvin"}',
'{"city":"杭州","units":"celsius","extra":true}',
'```json\n{"city":"杭州","units":"celsius"}',
]
最后一个用例是否通过,取决于系统是否允许“代码围栏修复”。如果允许,应明确测试:
原始值:非 JSON
修复后:合法 JSON
修复标记:true
再次 Schema 校验:通过
不能让测试只验证最终对象,而忽略系统曾经进行了什么修复。
十八、常见误解和反例
误解一:开启严格模式后,客户端不需要校验
反例:工具定义在缓存、代理或版本迁移过程中发生改变。客户端若不重新校验,就可能执行不符合本地预期的参数。
误解二:JSON 能解析就可以执行
反例:
{
"user_id": "user_123",
"role": "admin"
}
这是合法 JSON,但它是否允许修改权限,取决于授权系统,而不是 JSON 解析器。
误解三:Schema 的 description 能替代约束
反例:
{
"level": {
"type": "string",
"description": "只能是 low、medium、high"
}
}
如果没有 enum,校验器不会限制取值。
误解四:新增字段永远向后兼容
反例:旧客户端使用 additionalProperties: false。服务端新增字段后,旧客户端会拒绝整个对象。
误解五:失败就让模型“再试一次”
反例:支付、退款、发送邮件等工具已经完成副作用,第二次执行可能造成重复操作。模型生成重试和工具执行重试必须分开处理。
误解六:MCP 的 structuredContent 就是模型结构化输出
MCP 规范明确指出,structuredContent 是服务端产生的工具结果数据,与 LLM 的 schema-constrained generation 无关。(modelcontextprotocol.io)
十九、可落地的最小原则
一个 Agent 结构化输出链路至少应满足:
1. 先识别响应通道,再解析 JSON
2. JSON parse 和 Schema validate 分开
3. Schema validate 和业务 validate 分开
4. 工具输入和工具输出分别定义 Schema
5. 所有副作用工具都使用幂等键
6. 自动修复必须有限、可记录、可重新校验
7. 重试必须有预算,不得无限循环
8. 并行调用必须使用独立 call_id
9. 版本号必须进入可观察的契约或协议元数据
10. 协议版本、工具 Schema 版本和业务语义版本分别管理
11. 旧版本适配通过显式转换完成,不依赖猜测
12. 结构化校验通过后仍需做权限和状态检查
最终可以把 Agent 的可信执行条件写成:
其中:
- :输出通道正确;
- :JSON 语法正确;
- :满足版本化 Schema;
- :满足动态业务规则;
- :权限和审批通过;
- :幂等性检查通过。
缺少任意一个条件,都只能返回“未执行”或“需要进一步处理”,不能把“模型看起来像是这么说的”当作执行依据。
系列导航与关联阅读
- 系列入口:Agent 工程完整路线:从运行循环、记忆与协议到安全、评测和生产交付
- 上一篇:Agent 模型选择与路由:能力、延迟、成本、回退和稳定性
- 下一篇:Agent 工具 Schema:命名、描述、参数、枚举和可发现性
- 延伸:Agent API 契约:会话、消息、附件、事件、错误和幂等键
官方资料
本文依据 Agent、模型、协议与框架官方资料重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论