Agent 工程体系 · 第 8/98 篇。内容以 2026 年 9 月可验证的公开规范和稳定接口为基线;框架版本敏感能力会明确标注,不把实验行为写成通用保证。

Agent 结构化输出:JSON Schema、严格解析、修复和版本兼容

Agent 不只是“让模型返回一段 JSON”。一个可运行的 Agent 系统需要回答四个不同的问题:

  1. 模型是否理解了输出结构?
  2. 返回文本是否是合法 JSON?
  3. 合法 JSON 是否满足约定的 JSON Schema?
  4. 这个结构在当前版本、旧版本和未来版本之间是否仍然可以被正确处理?

这四个问题分别对应生成约束、语法解析、语义校验和契约兼容。如果把它们混成一个“解析 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 解析只能回答:

文本JSON parseJSON value\text{文本} \xrightarrow{\text{JSON parse}} \text{JSON value}

它不能回答:

JSON valueSchema validation业务上是否可接受\text{JSON value} \xrightarrow{\text{Schema validation}} \text{业务上是否可接受}

2. JSON Schema 是可执行的结构契约

JSON Schema 是描述 JSON 值约束的一种声明式语言。它可以定义:

  • JSON 类型:objectarraystringnumberintegerbooleannull
  • 对象属性: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 表示:

  • 根值必须是对象;
  • 对象只能包含 cityunits
  • 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. 版本归一化:是否能转换为当前内部模型?

可以形式化为:

A(x)=N(V(P(T(x))))A(x) = N(V(P(T(x))))

其中:

  • TT:通道识别,判断输出类型;
  • PP:JSON 语法解析;
  • VV:JSON Schema 校验;
  • NN:版本归一化;
  • AA:应用层最终接受的结构。

只有当所有步骤成功时,结果才可以进入业务流程。

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 valid⇏Business valid\text{Schema valid} \not\Rightarrow \text{Business valid}

Schema 主要保证数据形状,业务校验保证数据在当前状态下可执行。


三、严格输出的真正含义

1. 严格解析与严格生成不是一回事

“严格”至少有三种含义:

严格生成

由模型服务端根据 Schema 约束生成结果,使模型输出更可靠地符合结构。

严格解析

客户端收到结果后,使用 JSON 解析器和 Schema 校验器验证,不接受模糊转换。

严格执行

只有在通道、Schema、权限、业务状态和幂等性都确认后,才执行外部副作用。

三者不能互相替代:

严格生成 ≠ 严格解析
严格解析 ≠ 严格执行
严格生成 + 严格解析 ≠ 自动安全

即使服务端声称输出遵循 Schema,客户端仍应进行独立校验。原因包括:

  • Schema 可能在代理层被修改;
  • 不同模型或 API 版本的支持范围不同;
  • 流式拼接可能被截断;
  • 中间件可能改变字段;
  • 工具调用可能来自旧版本缓存;
  • 结构合法但权限不合法。

2. OpenAI 函数调用中的严格模式

OpenAI 的函数调用使用 JSON Schema 描述工具参数。严格模式用于提高函数参数遵循 Schema 的可靠性,官方文档建议通常启用 strict。严格模式要求对象的 additionalPropertiesfalse,并要求所有 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 工具结果还可以携带符合 outputSchemastructuredContent。(modelcontextprotocol.io)

这两者的层次不同:

层次 作用 典型字段
模型工具选择 模型决定调用哪个工具 name
工具输入契约 约束模型生成的参数 parametersinputSchema
工具执行协议 客户端调用服务端工具 tools/call
工具输出契约 约束工具返回的数据 outputSchemastructuredContent
最终回答结构 约束 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 时间戳和浮点金额都需要额外的单位和精度约定。特别是金额,二进制浮点数可能产生:

0.1+0.20.30.1 + 0.2 \neq 0.3

在支付和结算场景中,应使用最小货币单位的整数,或使用明确的十进制定点类型。


六、严格解析:从字符串到可信对象

下面给出一个不依赖特定模型厂商 SDK 的 Python 解析器。它分离了:

  1. JSON 语法解析;
  2. Schema 校验;
  3. 业务校验;
  4. 版本归一化。

先安装 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 代码围栏。"
}

这种反馈具有三个优点:

  1. 模型知道失败位置;
  2. 模型知道失败约束;
  3. 系统可以对错误分类和统计。

但不要把内部校验器的全部错误原样暴露给模型。例如路径中可能包含内部表名、权限信息或敏感数据。应先做错误脱敏和归类。

重试不应重新执行副作用

错误发生在模型生成阶段时,可以重试生成;错误发生在工具执行阶段时,不能简单把同一个工具调用再执行一次。

例如:

模型生成支付请求
  → Schema 通过
  → 支付服务超时

此时不应直接再次创建支付。必须使用幂等键:

{
  "payment_id": "pay_123",
  "amount_minor": 1000,
  "currency": "CNY",
  "idempotency_key": "agent-run-abc-step-7"
}

执行层需要保证:

execute(k,x)={首次执行结果,k 未出现过已记录结果,k 已出现且请求等价冲突错误,k 已出现但请求不同\text{execute}(k, x) = \begin{cases} \text{首次执行结果}, & k \text{ 未出现过}\\ \text{已记录结果}, & k \text{ 已出现且请求等价}\\ \text{冲突错误}, & k \text{ 已出现但请求不同} \end{cases}

这里:

  • kk 是幂等键;
  • xx 是请求参数;
  • “请求等价”至少应包括工具名、版本和规范化后的参数。

九、并发与严格输出:多个工具调用不是一个对象

在支持并行工具调用的模型中,一次响应可能包含多个工具调用。OpenAI 文档说明,模型可以在一个轮次中调用多个函数;设置 parallel_tool_calls: false 可以限制为零个或一个工具调用。(developers.openai.com)

因此,以下两种结构完全不同:

{
  "name": "get_weather",
  "arguments": {
    "city": "杭州"
  }
}
[
  {
    "name": "get_weather",
    "arguments": {
      "city": "杭州"
    }
  },
  {
    "name": "get_weather",
    "arguments": {
      "city": "上海"
    }
  }
]

并行调用带来两个额外问题。

1. 调用之间是否独立

只有当工具调用满足:

i,j, ij: dependency(calli,callj)=\forall i,j,\ i \neq j:\ \text{dependency}(call_i, call_j)=\varnothing

才适合真正并行。

例如:

查询杭州天气
查询上海天气

通常可以并行。

但:

创建订单
扣减库存

通常存在顺序和事务依赖,不能因为模型一次返回两个调用就并行执行。

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 工具定义包含工具名、描述和 inputSchemainputSchema 必须是合法 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

出现新版本时,需要分别考虑:

  • 向后兼容:新服务端能否处理旧客户端的输入;
  • 向前兼容:旧客户端能否处理新服务端的输出;
  • 跨版本转换:是否存在明确的适配器。

一个简单的兼容模型是:

C(Sa,Sb)={true,所有 Sb 的有效输入都属于 Safalse,存在 xSb, xSaC(S_a, S_b) = \begin{cases} \text{true}, & \text{所有 } S_b \text{ 的有效输入都属于 } S_a\\ \text{false}, & \text{存在 } x \in S_b,\ x \notin S_a \end{cases}

但实际系统还要考虑输出消费方,因此输入和输出必须分别评估。

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 协议版本兼容,也不代表某个工具的 inputSchemaoutputSchema 兼容。协议负责“消息如何传输”,工具 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_idconfirmation_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 的可信执行条件写成:

Execute(x)    T(x)P(x)Vschema(x)Vbusiness(x)A(x)I(x)\text{Execute}(x) \iff T(x) \land P(x) \land V_{\text{schema}}(x) \land V_{\text{business}}(x) \land A(x) \land I(x)

其中:

  • T(x)T(x):输出通道正确;
  • P(x)P(x):JSON 语法正确;
  • Vschema(x)V_{\text{schema}}(x):满足版本化 Schema;
  • Vbusiness(x)V_{\text{business}}(x):满足动态业务规则;
  • A(x)A(x):权限和审批通过;
  • I(x)I(x):幂等性检查通过。

缺少任意一个条件,都只能返回“未执行”或“需要进一步处理”,不能把“模型看起来像是这么说的”当作执行依据。


系列导航与关联阅读

官方资料

本文依据 Agent、模型、协议与框架官方资料重新梳理;正文、示例与生产清单由 WR BLOG 编写。