雨天小六

读懂 Codex(1.3):结构化输出为什么能表达 Tool Call

· 更新于 2026-08-01 · 专栏:读懂 Codex

#Codex#Agent Runtime#Tool Calling#Responses API#软件架构

让模型“使用工具”的最粗糙方案,是要求它在回答中输出一段约定文本:

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,它只能说明模型应该返回一个命令 字符串,不能说明这条命令符合策略、获得用户审批、可以在当前沙箱中执行,更不能说明 它已经执行成功。

ToolSpec 经 Responses 请求进入模型,FunctionCall 经 SSE 反序列化和 ToolRouter 进入 Registry 的结构化调用流程图
图 1.3-1:Tool Spec 引导模型选择输出类型和参数形状;ResponseItem 让 Runtime 可以确定性分派。Schema 不代替 Registry、Policy 或真实执行。

结构化的第一层: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 在执行前做确定性验证; 两道防线的时间点与信任等级不同。

idcall_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,而不是等输出时再根据顺序猜测。

FunctionCall 的 Item id、call_id、namespace、name、arguments 和元数据字段如何进入 Registry 并形成 ToolInvocation 的类型与分支图
图 1.3-2:FunctionCall 外层结构先分离 Item 身份、调用关联键、路由名和原始载荷。Router 只负责归一化,Registry 仍需检查名称和 payload 种类。

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参数语义
FunctionCallFunction含 JSON 的原始字符串
CustomToolCallCustomfreeform 字符串
client ToolSearchCallToolSearch已解析的搜索参数
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 kindFatal更像 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 主要解决四个问题:

  1. 用 ResponseItem 类型区分人类可读回答与机器可分派意图;
  2. 用 Tool Spec 和 Schema 描述模型能请求什么及参数形状;
  3. call_id 建立 Call 与未来 Output 的稳定关联;
  4. 用 ToolRouter 和 Registry 把 Provider 类型归一化为可校验的本地 Invocation。

它没有解决的是工具执行本身。一个调用意图怎样在并发和取消条件下执行,结果怎样 保持 call_id 并进入历史,Runtime 又为什么必须等所有工具收束后才发起下一次模型采样, 是 1.4 要解决的问题。

评论


← 返回文章列表