Function call 不只是 JSON:从工具协议到 Schema 约束解码
以 edit 工具的演进为切口,系统拆解 function call 的运行闭环、JSON Schema 的四层约束、Constrained Decoding 的自动机与 token 掩码,以及生产 Agent 必须保留的校验、重试和权限边界。
先给结论: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) | 字段少,却产生巨大载荷 |
| 文本 diff | SEARCH/REPLACE、udiff | 用最小差异降低成本和回归 |
| 原生 search-replace | oldText / 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. 一套可落地的实现准则
- 先定义边界,再写描述。 把工具名、必填字段、枚举、路径范围和拒绝条件写进 schema;把业务真值留给 validator。
- 把输入和输出分开。 工具参数由模型提出,工具结果由 runtime 生成;不要把模型回显的“执行成功”当成真实结果。
- 结构字段尽量短,内容字段允许长。 对 edit 使用局部差异;对查询结果使用稳定的结构化对象,避免整段自然语言作为下游输入。
- 所有工具都要可失败。 错误要有稳定
code、人类可读message和retryable,并限制自动重试次数。 - 把并行调用当成显式能力。 只有互不依赖的只读工具才并发;写操作需要冲突检测、顺序或幂等键。
- 把权限放在模型之外。
allowedPaths、网络白名单、资源配额、人工确认和回滚点都应由 runtime 决定。 - 记录 schema 版本。 工具参数一旦进入长期会话或缓存,字段重命名要有兼容策略,不能只更新 prompt。
- 用失败样本测 schema。 关注缺字段、额外字段、歧义锚点、Unicode、超长参数、重复调用和工具返回错误,不要只测一次 happy path。
结语:最好的 schema 是一套反馈系统
Function call 的核心不是让模型“像程序一样思考”,而是把概率生成包进一条可验证、可回退的协议。原生 tool use 解决调用边界,JSON Schema 描述结构,约束解码在 token 层提前屏蔽非法前缀,validator 负责语义,runtime 再负责权限与副作用。
edit 工具的历史把这条路线说得很直白:载荷从整文件缩到局部差异,校验从模型自觉移到工具端,失败信息从日志变成下一轮输入,最后把不可逆操作放到 diff review 和权限门禁之后。
所以,设计 Agent 工具时最值得追求的不是“让模型永远不犯错”,而是让每一次错误都能被快速、确定地发现,并产生足够具体的反馈;当错误不可避免时,系统仍然能够安全地收敛。
Sources
- Function calling — OpenAI
- Structured outputs — OpenAI
- Tool use overview — Anthropic
- Function calling — Google AI for Developers
- Structured generation — Outlines
- xgrammar — MLC AI
- lm-format-enforcer — GitHub
- Efficient Guided Generation for Large Language Models — LMSYS