让模型“使用工具”的最粗糙方案,是要求它在回答中输出一段约定文本:
CALL shell {"command": "pytest"}
然后 Runtime 用正则表达式从 assistant 文本里找 CALL。这种方法做 Demo 很快,但它把
三件本应分开的事混在了一起:给人看的自然语言、给机器分派的控制信息,以及要交给
具体工具的参数。模型只要多输出一个代码块、引号或示例,解析器就可能把说明文字
当成真调用。
Codex 走的不是这条路。在它的协议中,Tool Call 是 ResponseItem 联合里一个有独立
类型标签的分支。模型要么输出 Message Item,要么输出 FunctionCall Item;Runtime 看 Item 类型
决定走消息路径还是工具路径,不需要从文本中猜。
但“结构化”不等于“可信任”,更不等于“已执行”。本节将沿着工具声明、模型输出、 SSE 解码、内部归一化和 Registry 校验这条路,把这三个概念分清。
先声明模型允许请求哪些能力
在模型产生 Tool Call 之前,Runtime 需要把 Tool Spec 附在 Responses 请求上。Function Tool 的 声明至少包含名称、描述、参数 JSON Schema 和 strict 开关。官方 Function Calling 合同同样把 这些工具定义视为请求的一部分,并把 function 的参数定义为 JSON Schema;模型返回的是 特殊工具调用,不是应用已执行工具的证明 (OpenAI Function Calling)。
可以把这个请求构造过程概括成:
def build_model_request(prompt, tool_router):
return ResponsesRequest(
instructions=prompt.base_instructions,
input=prompt.history,
tools=[
encode_for_provider(spec)
for spec in tool_router.model_visible_specs
],
parallel_tool_calls=prompt.parallel_tool_calls,
)
Tool Spec 是“模型可见能力表”。Schema 越精确,模型越容易给出合适的字段名、类型
和必填值;strict 会进一步缩小参数形状的自由度。但它们都属于模型生成合同,不是
操作系统的权限凭证。
这个边界很重要。假设 Schema 里有 command: string,它只能说明模型应该返回一个命令
字符串,不能说明这条命令符合策略、获得用户审批、可以在当前沙箱中执行,更不能说明
它已经执行成功。
结构化的第一层:Item 类型与普通文本分离
Codex 的协议不把一次模型响应等同于一条 assistant 字符串。ResponseItem 是一个带类型
标签的联合,Message、Reasoning、FunctionCall、CustomToolCall、ToolSearchCall 和多种 Output 都是
并列分支。
用伪代码表示:
ResponseItem = (
MessageItem
| ReasoningItem
| FunctionCallItem
| FunctionCallOutputItem
| CustomToolCallItem
| CustomToolCallOutputItem
| ToolSearchCallItem
| ToolSearchOutputItem
| HostedToolItem
| ...
)
因此,下面两个输出在 Runtime 里有本质差异:
Message("I should inspect the repository.")
FunctionCall(name="exec_command", arguments="{...}", call_id="call_42")
第一个是模型说了一句话,第二个是模型提交了一个调用意图。即使 Message 的文本 恰好写成 JSON,ToolRouter 也不应因此执行它;即使 FunctionCall 的参数字符串最后解析失败, 它仍然是一个调用 Item,只是调用无法通过后续校验。
这就是结构化输出最核心的价值:把控制面从自然语言的字面内容中抽离出来。Runtime 只需做 类型匹配,不需要判断一段话是示例、解释、拒绝还是真实命令。
结构化的第二层:外层强类型,内层参数延迟解析
FunctionCall Item 本身是强类型的,其载荷却有一个刻意保留的弱类型边界:
arguments 是“包含 JSON 的字符串”,而不是所有工具共用的一个巨大参数 enum。
@dataclass
class FunctionCallItem:
id: str | None # 这个 ResponseItem 的身份
name: str # 本地工具名
namespace: str | None # 可选命名空间
arguments: str # 原始 JSON 字符串
call_id: str # 调用与 Output 的关联键
encrypted_arguments: str | None
metadata: dict | None
为什么不在 SSE 解码时就把 arguments 转成具体类型?因为参数类型属于具体工具,不属于
Responses 通用传输层。Shell、Apply Patch、MCP 和扩展工具的参数会各自演进。如果传输层要
认识每一种业务参数,新增工具就必须同时修改协议解析器。
延迟解析则把职责分成两层:
def decode_protocol_item(raw_event) -> FunctionCallItem:
# 只校验 Responses FunctionCall 的通用外形。
return FunctionCallItem(
id=raw_event.item.get("id"),
name=require_string(raw_event.item, "name"),
namespace=optional_string(raw_event.item, "namespace"),
arguments=require_string(raw_event.item, "arguments"),
call_id=require_string(raw_event.item, "call_id"),
)
def parse_business_arguments(handler, call):
# 只有选中 Handler 后,才用该工具的参数类型解析。
return handler.argument_type.from_json(call.arguments)
这不是说 Schema 没有价值。Schema 在生成前限制形状,Handler 在执行前做确定性验证; 两道防线的时间点与信任等级不同。
id 与 call_id 是两种身份
FunctionCall 里有两个很容易混淆的标识符。
id属于 ResponseItem,用于流式事件、历史记录、UI 映射与 Item 级引用;call_id属于一次调用对话,工具 Output 必须引用它,模型才知道结果对应哪个 Call。
可以将它们类比为“信封编号”和“案件编号”。FunctionCall Item 与 FunctionCallOutput Item 是两个 不同信封,所以各自可有 Item id;但它们处理同一个调用,所以共享 call_id。
call = FunctionCallItem(
id="fc_abc",
call_id="call_42",
name="exec_command",
arguments='{"cmd":"pytest"}',
)
output = FunctionCallOutputItem(
id="fco_xyz",
call_id="call_42",
output="...",
)
把 Item id 误当 call_id,会直接破坏工具调用配对。因此 Codex 在 Router、Invocation、Tool Result 与 回放中持续传递 call_id,而不是等输出时再根据顺序猜测。
namespace 不是装饰字段
一个 FunctionCall 可以只有 name,也可以携带 namespace。Codex 在建立内部 ToolName 时会
保留两者,Registry 查找的是组合名,而不是丢弃 namespace 后的短名。
@dataclass(frozen=True)
class ToolName:
namespace: str | None
name: str
def registry_key(item):
return ToolName(namespace=item.namespace, name=item.name)
这一点对扩展尤其重要。两个 Provider 或插件可以都提供 search,但分别属于不同 namespace。
如果 Router 只保留短名,就会在分派时产生冲突,甚至调到错误工具。Codex 的 Router 测试专门覆盖
FunctionCall 和 CustomToolCall 的 namespace 保留,集成测试还验证它能穿过分派和回放。
Function、Custom 与 Tool Search 怎样归一化
并非所有工具调用都携带 JSON 字符串。Codex 的 ToolRouter 把外部类型归一化为三种
ToolPayload:
| 外部 Item | 内部 Payload | 参数语义 |
|---|---|---|
| FunctionCall | Function | 含 JSON 的原始字符串 |
| CustomToolCall | Custom | freeform 字符串 |
| client ToolSearchCall | ToolSearch | 已解析的搜索参数 |
def build_tool_call(item) -> ToolCall | None:
match item:
case FunctionCallItem():
return ToolCall(
tool_name=ToolName(item.namespace, item.name),
call_id=item.call_id,
payload=FunctionPayload(arguments=item.arguments),
encrypted_arguments=item.encrypted_arguments,
)
case CustomToolCallItem():
return ToolCall(
tool_name=ToolName(item.namespace, item.name),
call_id=item.call_id,
payload=CustomPayload(input=item.input),
)
case ToolSearchCallItem(execution="client", call_id=call_id):
params = parse_search_params(item.arguments)
return ToolCall(
tool_name=ToolName(None, "tool_search"),
call_id=call_id,
payload=ToolSearchPayload(params),
)
case ToolSearchCallItem(execution="server"):
return None # Provider 托管工具,不在本地重复执行。
case _:
return None
Function 适合可由 JSON Schema 表达的结构化参数;Custom Tool 保留 freeform 输入,可承载 不适合强行拆成 JSON 字段的语法;Tool Search 还要区分 client 与 server 执行方。这三者可以进入 统一的调度管线,但不应丢掉原始 payload 种类,因为 Output 类型和 Handler 能力检查都依赖它。
SSE delta 不是分派边界
模型输出通过 SSE 逐步到达。粗略看来,似乎可以每收到一段 arguments delta 就尝试执行。 这会破坏完整性:JSON 可能还没闭合,后续 delta 也可能改变字符串的语义。
Codex 将 response.output_item.done 里的完整 Item 反序列化为 ResponseItem,然后才交给
ToolRouter。某些 custom tool input delta 可以作为流式事件显示,但它们不是真正的调用承诺点。
async def consume_sse(stream):
async for event in stream:
if event.type == "response.custom_tool_call_input.delta":
emit_optional_ui_delta(event.delta)
elif event.type == "response.output_item.done":
item = decode_complete_response_item(event.item)
yield OutputItemDone(item)
elif event.type == "response.completed":
yield Completed(event.response)
这个设计保留了两个边界:流式 delta 服务于响应性,完整 Item 服务于确定性分派。 如果 Item 反序列化失败,当前解析路径不会把原始 JSON 伪装成 assistant Message 来猜测调用。
Registry 才回答“这个调用能否被处理”
ToolRouter 建好统一 ToolCall 后,并没有直接执行字符串。Registry 首先用完整 ToolName
找 Handler,然后检查 Handler 是否支持当前 payload kind。通过后才会组装 ToolInvocation:
@dataclass
class ToolInvocation:
session: Session
turn_context: TurnContext
step_context: StepContext
call_id: str
tool_name: ToolName
payload: ToolPayload
cancellation: CancellationToken
tracker: ToolCallTracker
source: ToolCallSource
注意 Invocation 不只是“名称 + 参数”。它绑定了这次调用所属的会话、当前回合与步骤快照、 取消信号和跟踪状态。后续 Policy、Hook、审批、沙箱和 Handler 才能在正确上下文里工作。
分派前的错误也不能简单分成“成功/崩溃”:
| 情况 | 结果 | 为什么 |
|---|---|---|
| 名称存在且 payload kind 匹配 | 构造 Invocation | 进入后续工具生命周期 |
| 未知工具 | RespondToModel | 模型可以看到错误并选择其他工具 |
| client ToolSearch 参数无法解析 | RespondToModel | 调用意图存在,参数可修正 |
| 已注册名称收到不可能的 payload kind | Fatal | 更像 Runtime 不变式破坏,不应误导模型只需换个参数 |
def resolve_invocation(tool_call, registry, context):
handler = registry.lookup(tool_call.tool_name)
if handler is None:
raise RespondToModel(
f"unknown tool: {tool_call.tool_name}"
)
if not handler.accepts(tool_call.payload.kind):
raise FatalRuntimeError(
"registered tool received an incompatible payload kind"
)
return ToolInvocation.from_call(tool_call, context)
RespondToModel 不表示这个错误无关紧要。它表示错误已经能被安全压缩为模型可见的工具
结果,Agent 有机会在下一次采样中自我修正。Fatal 则说明继续循环可能隐藏程序错误或
破坏历史不变式。完整错误边界将在 1.6 展开。
一个最小可实现路由器
将前面的设计合在一起,Mini Codex 中的最小路由器可以写成:
class ToolRouter:
def __init__(self):
self._handlers: dict[ToolName, ToolHandler] = {}
self._model_visible_specs: list[ToolSpec] = []
def register(self, name, spec, handler):
if name in self._handlers:
raise ConfigurationError(f"duplicate tool: {name}")
self._handlers[name] = handler
self._model_visible_specs.append(spec)
@property
def model_visible_specs(self):
return tuple(self._model_visible_specs)
def decode_call(self, item) -> ToolCall | None:
# 只做协议归一化,不执行副作用。
return build_tool_call(item)
def bind(self, call, step_context) -> ToolInvocation:
handler = self._handlers.get(call.tool_name)
if handler is None:
raise RespondToModel(f"tool not found: {call.tool_name}")
if not handler.accepts(call.payload.kind):
raise FatalRuntimeError("tool/payload contract mismatch")
return ToolInvocation(
handler=handler,
call_id=call.call_id,
payload=call.payload,
step_context=step_context,
cancellation=step_context.cancellation.child_token(),
)
这份伪代码特意没有 execute()。本节的终点是得到一个“类型已识别、身份可关联、
名称可查找、载荷未被篡改、并绑定当前步骤上下文”的 Invocation。从这里到实际执行之间,
还有并发门、Hook、Policy、审批、沙箱、取消与 Output 转换。
应该如何测试这层合同
这一层不需要真正启动 shell 才能验证。应优先做协议与路由测试:
def test_message_that_looks_like_json_is_not_a_call():
item = MessageItem(text='{"name":"exec_command"}')
assert router.decode_call(item) is None
def test_namespace_is_part_of_registry_identity():
item = FunctionCallItem(
namespace="workspace",
name="search",
arguments='{"query":"ToolRouter"}',
call_id="call_1",
)
call = router.decode_call(item)
assert call.tool_name == ToolName("workspace", "search")
def test_unknown_tool_is_recoverable_for_the_model():
call = ToolCall(ToolName(None, "missing"), "call_2", FunctionPayload("{}"))
with raises(RespondToModel):
router.bind(call, step_context)
def test_payload_kind_mismatch_is_runtime_fatal():
router.register_function("search", search_spec, function_handler)
call = ToolCall(
ToolName(None, "search"),
"call_3",
CustomPayload("free text"),
)
with raises(FatalRuntimeError):
router.bind(call, step_context)
Codex 当前的测试覆盖了 Function/Custom namespace 路由、未知 Custom Tool 的类型化错误输出、 namespace 穿过分派与回放,以及 Tool Search Item 的 SSE 解码。这些测试共同保护的不是 某个 Rust 函数的写法,而是整条合同不变式。
小结:结构化输出产生的是可验证意图
结构化 Tool Call 主要解决四个问题:
- 用 ResponseItem 类型区分人类可读回答与机器可分派意图;
- 用 Tool Spec 和 Schema 描述模型能请求什么及参数形状;
- 用
call_id建立 Call 与未来 Output 的稳定关联; - 用 ToolRouter 和 Registry 把 Provider 类型归一化为可校验的本地 Invocation。
它没有解决的是工具执行本身。一个调用意图怎样在并发和取消条件下执行,结果怎样 保持 call_id 并进入历史,Runtime 又为什么必须等所有工具收束后才发起下一次模型采样, 是 1.4 要解决的问题。
评论
登录后即可评论