← blog 技术原理与实践 · 2026-10-03

Function call 不只是 JSON:从工具协议到 Schema 约束解码

以 edit 工具的演进为切口,系统拆解 function call 的运行闭环、JSON Schema 的四层约束、Constrained Decoding 的自动机与 token 掩码,以及生产 Agent 必须保留的校验、重试和权限边界。

22 min read

先给结论:schema 是一条跨层的控制边界

大语言模型不会执行函数。它只是在当前上下文中继续生成 token;真正调用天气 API、修改文件或执行数据库查询的,是模型外面的 Agent runtime。所谓 function call,是 runtime 与模型约定的一轮协议:

sequenceDiagram
    participant U as 用户
    participant A as Agent runtime
    participant M as LLM
    participant T as 工具
    U->>A: 自然语言请求
    A->>M: 对话 + 工具定义 + schema
    M-->>A: tool_call(name, arguments)
    A->>A: JSON 解析、schema 校验、权限检查
    A->>T: 执行已批准的参数
    T-->>A: tool_result 或结构化错误
    A->>M: 回填结果
    M-->>A: 下一次 tool_call 或最终回答
    A-->>U: 最终结果

这里至少有四层约束:

层解决的问题典型手段
协议层模型何时调用、调用哪个工具、如何回填tool_calls、tool_use、functionCall
语法层参数能否被解析、字段和类型是否正确JSON Schema、JSON parser
语义层参数是否真的有业务意义Pydantic/Zod、数据库和领域校验
策略层这次副作用是否允许执行sandbox、allowlist、人工确认、幂等键

Schema 主要负责前两层,约束解码只是在模型生成阶段提前加强语法层。它不能替代业务校验,也不能替用户批准一项危险操作。

1. 一次 function call 到底发生了什么

一个最小的工具定义可能是:

{
  "type": "function",
  "function": {
    "name": "get_weather",
    "description": "查询指定城市未来一天的天气",
    "parameters": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "city": { "type": "string", "description": "城市名,例如北京" },
        "unit": { "type": "string", "enum": ["celsius", "fahrenheit"] }
      },
      "required": ["city", "unit"]
    }
  }
}

runtime 把它和对话一起交给模型。模型输出的不是函数返回值,而是一个待执行的意图:

{
  "id": "call_123",
  "name": "get_weather",
  "arguments": "{\"city\":\"北京\",\"unit\":\"celsius\"}"
}

不同供应商的外形不同。OpenAI 在 assistant 消息中返回 tool_calls,参数通常是字符串化 JSON;Anthropic 把 tool_use 和 tool_result 放进内容块;Gemini 使用结构化的 functionCall / functionResponse。这些是传输协议差异,Agent 内部仍然要完成同一组动作:识别调用、解析参数、校验、执行、回填。

def run_agent(messages, tools, max_steps=12):
    for _ in range(max_steps):
        response = model.generate(messages=messages, tools=tools)

        if not response.tool_calls:
            return response.text

        for call in response.tool_calls:
            # arguments 是不可信输入,即使服务端声称支持 strict schema
            args = json.loads(call.arguments)
            spec = registry[call.name]
            checked = spec.input_model.model_validate(args)
            authorize(call.name, checked)
            result = execute(call.name, checked)
            messages.append(tool_result(call.id, result))

        # 让模型看到结果后决定继续调用还是回答
    raise AgentLoopLimit("too many tool calls")

max_steps、工具注册表和 authorize 不是装饰品。没有循环上限,模型可能在同一个错误上打转;没有注册表,模型生成的名字可能被当成动态代码执行;没有权限层,合法 JSON 也可能造成越权写入。

2. Schema 如何影响模型:从描述到可验证契约

很多人把 schema 理解成“把函数签名翻译成 JSON”。这还不够。一个可用 schema 同时决定了模型的选择空间、runtime 的校验边界和错误反馈的质量。

2.1 描述决定选择,类型决定形状

description 解释“什么时候应该调用”,properties 解释“调用时要填什么”。工具名要短而独特;参数描述应该说清单位、取值范围和缺省语义;互斥选项优先使用 enum 或联合结构,而不是让模型自由拼字符串。

{
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "path": { "type": "string", "description": "仓库内相对路径,不得以 / 或 .. 开头" },
    "edits": {
      "type": "array",
      "minItems": 1,
      "items": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "oldText": { "type": "string", "description": "原文中唯一出现的精确片段" },
          "newText": { "type": "string", "description": "替换后的文本,可为空" }
        },
        "required": ["oldText", "newText"]
      }
    }
  },
  "required": ["path", "edits"]
}

2.2 additionalProperties: false 是边界,不是格式偏好

允许任意额外字段,会让模型“顺手”加入 runtime 不认识的内容,也让版本演进变得含糊。关闭它能把未知字段尽早变成错误。对于确实需要扩展的工具,显式设计 metadata 或 options 对象,并为其定义自己的 schema。

2.3 减少载荷会减少错误面

edit 工具的演进很能说明问题。整文件 write_file(path, content) 看起来只有两个参数,却迫使模型回写所有没有变化的内容;长参数增加 token 成本,也增加尾部截断、隐藏回归和无关改动。search/replace、unified diff 和多文件 edits[] 都在做同一件事:让模型只提交差异。

因此,schema 的“字段少”不等于好;更准确的判断是:一次调用中,模型需要重新生成多少本来已经存在的信息。

3. 三种让模型产生结构化调用的路线

3.1 提示词协议:ReAct

最早的 Agent 常要求模型输出:

Thought: 需要查询天气
Action: get_weather
Action Input: {"city":"北京"}

runtime 用正则或解析器提取 Action。这种方式几乎不依赖模型厂商,适合原型和不支持工具协议的模型,但自由文本会出现漏写字段、混入 Markdown 和 JSON 不闭合。解析失败只能重试。

3.2 原生 tool use:协议 token 或服务端结构

经过工具调用数据训练的模型能更稳定地区分“给用户说的话”和“给 runtime 的调用”。服务端通常会返回专门的调用结构,或使用训练过的边界 token。模型仍然是在生成序列,但协议把调用从普通文本中分离出来,runtime 无须猜测一段自然语言是不是 Action。

这提升了协议稳定性,却不自动保证每个参数都正确。city 可以是不存在的城市,oldText 也可能不是文件中的唯一片段;工具端校验仍然必要。

3.3 约束解码:在 token 被选中前把非法候选删掉

约束解码把“请输出合法 JSON”从概率建议变成生成时的硬边界。它不等模型生成完再检查,而是在每一步 logits 上应用 mask:

p'(t_i | prefix) = softmax(logits_i + mask_i)

其中合法 token 的 mask=0,当前 schema 下不可能通往完成结果的 token 的 mask=-∞。非法 token 的概率因此为零。

这三种路线不是互斥的。生产系统常用原生 tool use 识别“要不要调工具”,再对 arguments 使用约束解码,最后仍由 validator 检查业务语义。

4. Constrained Decoding 的内部:Schema 如何变成 token mask

4.1 编译期:把 schema 变成可推进的状态机

运行时不能每一步重新理解整份 schema。常见做法是先编译:

JSON Schema / regex / EBNF
                │
                ▼
      自动机或增量解析器
                │
                ▼
   状态 × token → 是否允许继续

对于没有递归的结构,可以把 schema 翻译成正则,再由 NFA 转 DFA。DFA 的状态表示“当前前缀处在 JSON 哪个位置”,例如刚输出 {、正在读键名、刚读完字符串值或等待 }。

递归对象和任意深度数组超出正则的表达能力。支持这类 schema 的实现会使用上下文无关文法和下推自动机(PDA):文法状态之外,还维护一份栈,遇到嵌套对象就压入返回点,闭合时弹出。xgrammar、llama.cpp 的 GBNF 等实现属于这一类思路。

另一条路线是维护字符级 JSON/regex parser。每次收到 token,就把它展开成字符或字节,询问解析器能否从当前状态继续。它不一定先构造完整 DFA,灵活性较好,但需要更谨慎地处理缓存和吞吐。

4.2 解码期:判断的是“可完成的前缀”

一个 token 可能包含多个字符,也可能只包含半个 UTF-8 字符、引号和键名的一部分,甚至跨越 , 与下一个键。自动机不能只检查“token 结束时是否已经是完整 JSON”,而要检查 token 的每个字符是否能推进,并且最终状态是否仍然存在一条通往接受态的路径:

def token_allowed(state, token_bytes, grammar):
    next_state = state
    for byte in token_bytes:
        next_state = grammar.step(next_state, byte)
        if next_state is DEAD:
            return False
    return grammar.can_reach_accept(next_state)

{"city":"北 不是完整 JSON,却是合法前缀;如果把“当前必须已接受”当作条件,中文字符串和所有多 token 字符串都会被错误拒绝。生产实现一般在 UTF-8 字节层处理,以覆盖 tokenizer 把多字节字符拆开的情况。

4.3 预计算:把每步遍历词表变成查表

词表可能有十万级 token。如果每生成一个 token,都把每个候选沿自动机走一遍,mask 成本会抵消推理收益。因此实现会在 schema 和 tokenizer 固定后缓存:

cache[current_state] = { token_id_1, token_id_2, ... }

Outlines 的 FSM 路线、xgrammar 的文法编译与结构类别压缩,都在优化这张“状态到合法 token 集”的表。一个 schema 被多个请求复用时,编译结果也可以复用;这正是结构化输出从 demo 走向服务端吞吐的关键。

4.4 三类策略的取舍

策略如何保证优点代价
FSM(regex → DFA)mask 掉不能继续的 token简单、快、容易缓存递归表达能力有限,键顺序可能膨胀
PDA/CFG用文法状态和栈推进能表达递归 JSON、EBNF编译和缓存更复杂
增量 parser逐 token 询问解析器灵活,易支持多种格式词表检查成本和实现细节更多
软约束给不希望出现的 token 加负偏置不改变太多原始分布不能承诺 100% 合规,必须有 validator

jsonformer 这类轻量实现还会把输出分成“结构模式”和“内容模式”:括号、键名、冒号等骨架强制生成,字符串内容交给模型自由生成。它实现简单,但不能覆盖所有 JSON Schema 语义;这个取舍也说明了一个实用原则:结构收紧,内容放开。

5. “符合 schema”保证到哪一步

约束解码的保证边界很容易被夸大。它通常能保证:

  • 输出可以被 JSON parser 解析;
  • 必需字段存在,字段类型和枚举值合法;
  • 对实现支持的 pattern、长度或范围约束,输出不会越过语法边界。

它通常不能单独保证:

  • city 是真实存在的城市;
  • oldText 在文件里恰好出现一次;
  • unit=celsius 时另一个字段满足业务条件;
  • 两个数组元素互不重复,或两个字段在数据库中保持一致;
  • 调用这个工具对当前用户是被授权的。

原因是这些约束需要外部状态、任意长度的记忆或业务知识。把 uniqueItems、跨字段关系和权限判断硬塞进生成语法,往往会产生巨大状态空间,甚至根本不属于正则或普通 CFG。

因此完整的生产链路应当是:

约束解码(语法)
        ↓
JSON parse + schema validator(类型)
        ↓
领域校验器 / 工具预检查(语义)
        ↓
权限、沙箱、幂等和人工确认(副作用)
        ↓
执行并回填结构化结果或可操作错误

6. edit 工具为什么是 function call 的好教材

文件编辑把 function call 的困难集中到一个参数里:代码文本既长又敏感,还可能有编码、空白和重复片段。它的演进可以概括为五代:

阶段形态学到的工程策略
无工具协议shell / ReAct 文本易接入,但格式漂移
整文件重写write_file(path, content)字段少,却产生巨大载荷
文本 diffSEARCH/REPLACE、udiff用最小差异降低成本和回归
原生 search-replaceoldText / newText工具端做唯一性和精确匹配
多文件与门禁edits[]、diff review、路径白名单合并轮次,把副作用交给 runtime 和用户

oldText 唯一、精确匹配失败就返回错误,是一个比“请模型仔细一点”可靠得多的 schema 设计。错误信息也不应是无上下文的 false,而应告诉模型下一步怎么修:

{
  "ok": false,
  "error": {
    "code": "OLD_TEXT_NOT_UNIQUE",
    "message": "oldText matched 3 locations; include the surrounding function signature",
    "retryable": true
  }
}

下一轮模型看到这个 tool_result 后,可以扩大锚点再试。失败反馈因此不是日志,而是 Agent 轨迹中的训练信号。它让系统从“希望模型一次说对”变成“错误可被确定性发现,并能推动下一轮收敛”。

7. 一套可落地的实现准则

  1. 先定义边界,再写描述。 把工具名、必填字段、枚举、路径范围和拒绝条件写进 schema;把业务真值留给 validator。
  2. 把输入和输出分开。 工具参数由模型提出,工具结果由 runtime 生成;不要把模型回显的“执行成功”当成真实结果。
  3. 结构字段尽量短,内容字段允许长。 对 edit 使用局部差异;对查询结果使用稳定的结构化对象,避免整段自然语言作为下游输入。
  4. 所有工具都要可失败。 错误要有稳定 code、人类可读 message 和 retryable,并限制自动重试次数。
  5. 把并行调用当成显式能力。 只有互不依赖的只读工具才并发;写操作需要冲突检测、顺序或幂等键。
  6. 把权限放在模型之外。 allowedPaths、网络白名单、资源配额、人工确认和回滚点都应由 runtime 决定。
  7. 记录 schema 版本。 工具参数一旦进入长期会话或缓存,字段重命名要有兼容策略,不能只更新 prompt。
  8. 用失败样本测 schema。 关注缺字段、额外字段、歧义锚点、Unicode、超长参数、重复调用和工具返回错误,不要只测一次 happy path。

结语:最好的 schema 是一套反馈系统

Function call 的核心不是让模型“像程序一样思考”,而是把概率生成包进一条可验证、可回退的协议。原生 tool use 解决调用边界,JSON Schema 描述结构,约束解码在 token 层提前屏蔽非法前缀,validator 负责语义,runtime 再负责权限与副作用。

edit 工具的历史把这条路线说得很直白:载荷从整文件缩到局部差异,校验从模型自觉移到工具端,失败信息从日志变成下一轮输入,最后把不可逆操作放到 diff review 和权限门禁之后。

所以,设计 Agent 工具时最值得追求的不是“让模型永远不犯错”,而是让每一次错误都能被快速、确定地发现,并产生足够具体的反馈;当错误不可避免时,系统仍然能够安全地收敛。

Sources

  1. Function calling — OpenAI
  2. Structured outputs — OpenAI
  3. Tool use overview — Anthropic
  4. Function calling — Google AI for Developers
  5. Structured generation — Outlines
  6. xgrammar — MLC AI
  7. lm-format-enforcer — GitHub
  8. Efficient Guided Generation for Large Language Models — LMSYS

Related

On this page 先给结论:schema 是一条跨层的控制边界1. 一次 function call 到底发生了什么2. Schema 如何影响模型:从描述到可验证契约2.1 描述决定选择,类型决定形状2.2 additionalProperties: false 是边界,不是格式偏好2.3 减少载荷会减少错误面3. 三种让模型产生结构化调用的路线3.1 提示词协议:ReAct3.2 原生 tool use:协议 token 或服务端结构3.3 约束解码:在 token 被选中前把非法候选删掉4. Constrained Decoding 的内部:Schema 如何变成 token mask4.1 编译期:把 schema 变成可推进的状态机4.2 解码期:判断的是“可完成的前缀”4.3 预计算:把每步遍历词表变成查表4.4 三类策略的取舍5. “符合 schema”保证到哪一步6. edit 工具为什么是 function call 的好教材7. 一套可落地的实现准则结语:最好的 schema 是一套反馈系统