雨天小六

读懂 Codex(6.1):Function Tool——模型拿到的是说明书,不是函数

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

#Codex#Agent Runtime#Function Calling#Tool#软件架构

假设 Codex 想让模型维护一份任务计划。真正更新计划的是客户端中的一段程序,但模型既不能引用 这段程序,也不能直接构造它需要的内部对象。模型能拿到的只有一份工具说明书:

{
  "type": "function",
  "name": "update_plan",
  "description": "更新当前任务计划",
  "strict": false,
  "parameters": {
    "type": "object",
    "properties": {
      "explanation": { "type": "string" },
      "plan": {
        "type": "array",
        "items": {
          "type": "object",
          "properties": {
            "step": { "type": "string" },
            "status": {
              "type": "string",
              "enum": ["pending", "in_progress", "completed"]
            }
          },
          "required": ["step", "status"],
          "additionalProperties": false
        }
      }
    },
    "required": ["plan"],
    "additionalProperties": false
  }
}

这段 JSON 是协议示例,不是 Codex 源码。它回答的是模型侧问题:工具叫什么、什么时候使用、参数 应该长什么样。至于哪个 Handler 真正执行、当前 Step 是否允许模型看到它、返回参数是否可信,都是 说明书之外的 Runtime 问题。

Function Tool 的设计,正是要把这两面接起来:模型面对一个稳定、有限的合同;Runtime 保留真正的 执行能力和最终校验权。

本文只讨论这份合同从建立到返回的完整周期。Namespace 合并、Freeform Grammar、具体工具业务、 审批和沙箱会在后续细节章单独展开。

一份工具合同解决四个不同问题

一个 Function Tool 看起来只是几个 JSON 字段,实际同时解决四个问题:

  1. 能力发现:模型需要知道当前有哪些动作可选;
  2. 参数生成:模型需要一个足够明确的结构,才能生成可解析的参数;
  3. 调用关联:Runtime 需要工具名和调用 ID,把请求、执行结果与历史配对;
  4. 安全隔离:模型只能提交调用意图,真正副作用仍留在客户端控制范围内。

如果只解决前两个问题,Function Calling 只是结构化文本生成;如果只解决后两个问题,模型又不 知道怎样调用。完整设计必须让“描述能力”和“执行能力”同时存在,却不让它们混成一个对象。

工具合同中的字段可以按职责分为三组:

分组字段设计作用
身份typename指明这是 Function Tool,并提供稳定的路由身份
引导descriptionparameters告诉模型何时调用以及参数如何组织
协议控制strictdefer_loading请求更严格的参数约束,或标记延迟装载行为

还有一个只在客户端内部使用的输出 Schema。它不会进入普通 Function Tool JSON。它与整次模型 回答使用的结构化输出 Schema 也不是同一概念:前者描述某个工具的内部结果,后者描述模型最终 回答应采用的结构。

description 是提示,不是权限规则

说明文字可以告诉模型“只有在任务包含多个阶段时才使用”,却不能保证模型一定遵守。它也不能 代替参数校验、审批或沙箱。

这条边界很重要。如果把安全要求只写进 description,例如“不要删除工作区外文件”,Runtime 仍然 必须假定模型可能生成违反要求的参数。自然语言描述负责降低误用概率,策略层负责阻止不允许的 行为。

strict 是协议请求,不是客户端免责条款

严格模式的目标是让 Provider 更强地约束模型输出,但客户端不能因此跳过校验。当前实现允许合同 携带严格标记,却没有在所有构造入口完整证明 Schema 已满足严格模式的全部前置条件。许多内建和 外部转换工具仍使用非严格模式。

即使未来所有 Schema 都通过本地严格检查,Runtime 仍要处理旧历史、不同 Provider、兼容层和程序 错误。因此,模型返回的参数始终应该被当作不可信输入。

延迟装载标记与“当前不可见”不是一回事

协议中可以带延迟装载标记,Runtime 内部也有“初始不展示、以后再发现”的可见策略。它们有关联, 但不是一个布尔值能描述的同一状态:前者属于发给模型的工具元数据,后者决定工具是否进入当前 Step 的初始工具面,以及它能否通过搜索或 Code Mode 被间接发现。

一个工具实现,为什么要同时产出两张表

Runtime 为每个工具保存两类信息:

  • 模型合同:名称、说明、输入 Schema 和协议选项;
  • 执行入口:收到调用后,由哪个 Handler 解析并执行。

这两类信息来自同一个工具实现包,但在一次模型采样中被拆成两张表:

Step 产物面向谁内容
模型可见合同列表模型请求当前允许模型直接选择的工具说明书
执行表Runtime工具名到实际 Handler 的映射

为什么不维护两套独立注册表?考虑一次常见改动:工具参数从 cmd 改为 command。如果模型合同先 更新,执行表仍指向只认识旧字段的 Handler,那么模型越严格遵守新合同,调用反而越稳定地失败。

Codex 的处理方式是:先从同一批工具实现建立当前 Step 的计划,再由这个计划同时产生模型合同和 执行表。这样不能消灭所有代码错误,但至少把“展示给模型的版本”和“处理返回调用的版本”冻结在 同一个快照里。

工具实现包在同一个 Step 中生成模型可见合同和执行表,合同经过普通 Responses 或 Lite 请求发送,模型返回原始参数后由同一快照中的 Handler 再次校验并执行的流程图
图 6.1-1:一次 Step 同时冻结“模型看见什么”和“谁负责执行”。请求可以有两种承载方式,但返回参数都会回到同一快照中的执行表。

这张图保护的是时间一致性。假设工具在两次采样之间升级:

Step A:模型看到参数 {"cmd": "..."}
Step B:模型看到参数 {"command": "..."}

Step A 发出的调用不能因为执行稍晚,就被 Step B 的 Handler 解释。正确做法是让 Step A 的合同和 执行表一起存活到该调用处理结束;下一次模型采样再采用 Step B 的新版本。

工具规划器决定的不是“有没有实现”,而是“本轮看不看得见”

工具已经存在,并不等于它一定出现在模型请求里。当前 Step 至少经过四层筛选:

筛选层需要回答的问题结果
来源门控配置、模型能力、账户、平台或环境是否允许建立它不允许时,连候选实现都不存在
可见策略它应直接展示、延迟发现、只在特定模式展示,还是完全隐藏决定是否进入初始合同列表
名称去重当前规范名称是否已经出现重复候选不会重复暴露给模型
运行模式Code Mode 等模式是否改变顶层工具面可能保留执行能力,但从顶层列表移走

这种设计把“可执行”和“可发现”分开。延迟工具可以先注册执行能力,等模型搜索到它后再装载合同; 隐藏工具可以保留兼容入口,却永远不让模型主动发现。

下面的伪代码只表达这套设计,不机械照搬 Rust。implementation.contract() 代表工具实现提供模型 合同,implementation.handler 代表同一实现提供执行入口:

def plan_tools(implementations: list[ToolImplementation], step: StepState) -> ToolPlan:
    visible_contracts: list[FunctionContract] = []
    dispatch_table: dict[ToolName, Handler] = {}
    visible_names: set[ToolName] = set()

    for implementation in implementations:
        name = implementation.canonical_name

        # 执行表保留当前 Step 收集到的实现;可见性另行判断。
        dispatch_table.register(name, implementation.handler)

        if name in visible_names:
            continue
        if not implementation.visibility.is_direct:
            continue
        if step.mode.hides_from_top_level(implementation):
            continue

        contract = adapt_contract_for_mode(implementation.contract(), step.mode)
        visible_contracts.append(contract)
        visible_names.add(name)

    return ToolPlan(
        contracts=merge_supported_namespaces(visible_contracts, step.provider),
        dispatch=dispatch_table,
        step_id=step.id,
    )

逐段看这段伪代码:

  1. 执行表和可见合同都在同一次循环中建立,来源快照一致;
  2. 执行表先保留实现,可见性判断不会把“不可见”误写成“不可执行”;
  3. 去重只控制模型合同,避免模型看到两个同名入口;
  4. 模式适配发生在发送之前,不能在模型已经生成调用后再改变合同;
  5. 最终计划带着 Step 身份,使返回调用能够找到原来的执行快照。

伪代码主动省略了各种具体工具来源、Namespace 冲突策略和 Deferred Tool Search。这里保留的只有 Function Tool 必需的不变量:合同与执行表必须来自同一次规划。

外部 Schema 不能原样塞进 Prompt

内建工具可以直接构造受控 Schema,MCP 和动态工具却可能带来不同生成器、不同方言甚至不完整的 JSON Schema。如果客户端把这些定义原样发送,会遇到三个问题:

  1. 外部 Schema 使用了客户端内部没有建模的关键字;
  2. 定义表带着大量根本没有被引用的类型,浪费 Prompt;
  3. 深层对象和组合类型过大,工具说明本身挤占模型上下文。

因此 Codex 先把外部 Schema 编译成一个受控子集。这里的“编译”不是生成机器码,而是经过正规化、 可达性分析、按需压缩和类型检查,得到可以稳定序列化的内部合同。

第一阶段:兼容性正规化

正规化会把常见但不适合内部表示的写法降级:

  • 常量约束改写成单成员枚举;
  • 缺少类型时,根据对象字段、数组元素、枚举或数值范围推断类型;
  • 对象没有字段表时补空字段表;
  • 数组没有元素 Schema 时补一个宽松默认值;
  • 不合法的定义表被删除;
  • 只剩未知关键字、无法解释的对象退化为空 Schema;
  • 引用和组合类型在可以保留时继续保留。

正规化优先考虑兼容性,不承诺语义无损。外部定义越依赖复杂方言,降级后的合同就越可能变宽。 这也是具体 Handler 必须保留最终校验权的另一个原因。

第二阶段:只留下能从根参数到达的定义

外部工具经常附带一整套定义表,而顶层参数只引用其中几个类型。可达性分析从根 Schema 中的引用 出发,继续追踪被引用定义内部的引用,最后删除从根永远走不到的条目。

这可以用一段图遍历伪代码表达:

def retain_reachable_definitions(schema: JsonObject) -> None:
    pending = collect_references_outside_definition_tables(schema)
    reachable: set[DefinitionPointer] = set()

    while pending:
        pointer = pending.pop()
        if pointer in reachable:
            continue

        reachable.add(pointer)
        definition = resolve_local_definition(schema, pointer)
        if definition is not None:
            pending.extend(collect_references(definition))

    for table in schema.definition_tables:
        table.keep_only(reachable)
        if table.is_empty():
            schema.remove(table)

reachable 不只用来裁剪,也负责终止循环。若 A 引用 B、B 又引用 A,第二次遇到 A 时会直接跳过, 不会无限递归。这里保留的是图遍历设计,而不是某种特定语言的容器写法。

第三阶段:只有过大时才逐级损失信息

正规化并裁剪定义后,系统用规范化 JSON 的 5000 bytes 作为约 1k token 的廉价代理。超过预算时, 按损失从小到大的顺序执行四轮压缩:

  1. 删除 Schema 中的说明文字;
  2. 删除定义表并处理失去目标的引用;
  3. 折叠从根向下深度达到 3 的复杂对象;
  4. 裁剪 anyOfoneOfallOf 等组合分支。

每一轮之前都重新测量,已经回到预算内就立即停止。5000 bytes 不是 Provider 的正式硬限制,四轮 之后也不保证一定小于该值;它只是一个低成本、与具体 Tokenizer 解耦的本地代理。

完整的 Schema 编译设计可以写成:

def compile_tool_schema(raw_schema: JsonValue, trusted: bool = False) -> InternalSchema:
    candidate = deep_copy(raw_schema)

    normalize_supported_keywords(candidate)
    retain_reachable_definitions(candidate)

    if not trusted:
        compaction_passes = (
            remove_schema_descriptions,
            remove_definition_tables,
            collapse_deep_objects,
            prune_composition_branches,
        )

        for compact in compaction_passes:
            if normalized_json_size(candidate) <= 5_000:
                break
            compact(candidate)

    schema = decode_internal_schema(candidate)
    if schema.accepts_only_null():
        raise InvalidToolSchema("工具没有可生成的有效参数")

    return schema

这段伪代码有四个需要注意的设计点:

  • 先正规化再测量,预算基于真正会进入内部合同的形状;
  • 先删除不可达定义,避免对本来就不需要发送的内容做有损压缩;
  • 可信路径只跳过大 Schema 压缩,不跳过正规化和最终类型检查;
  • 顶层只能是 null 的工具没有可生成参数,因此直接拒绝;可为空的联合类型不等同于只能为空。
外部输入 Schema 经过兼容性正规化、可达性裁剪和按需的四级压缩,最终成为模型可见 Function Tool 输入合同的流程图
图 6.1-2:先做无关定义裁剪,再按损失从小到大压缩。内部输出 Schema 不进入普通工具 JSON;图中绿色节点才是最终发送给模型的输入合同。

同一份合同为什么有两种请求放置方式

工具计划完成后,合同进入当前模型请求。普通 Responses 请求和 Responses Lite 使用不同承载方式:

请求模式工具放在哪里顶层 tools并行调用
普通 Responses请求顶层的工具数组存在取当前 Prompt 设置
Responses Lite输入序列开头的 developer 工具项不存在当前实现关闭

Lite 还会把基础指令改写成紧随其后的 developer Message。因此抓包时看不到顶层 tools,不能直接 判断工具丢失;必须先确认当前请求采用哪种协议模式。

普通路径会先把整组工具合同编码成一段 Raw JSON,并让请求克隆共享这段已编码数据。这样可以避免 反复建立通用 JSON 树。Lite 需要把工具放进输入项,所以仍然使用可以嵌入事件对象的 JSON 值。

请求编码的设计伪代码如下:

def encode_model_request(prompt: PromptSnapshot, mode: ApiMode) -> ApiRequest:
    if mode is RESPONSES_LITE:
        prefix = [
            DeveloperToolList(
                tools=[contract.to_json() for contract in prompt.tool_contracts]
            )
        ]

        if prompt.instructions:
            prefix.append(DeveloperMessage(prompt.instructions))

        return ApiRequest(
            input=prefix + prompt.history,
            instructions="",
            tools=None,
            parallel_tool_calls=False,
        )

    return ApiRequest(
        input=prompt.history,
        instructions=prompt.instructions,
        tools=SharedRawJson.encode(prompt.tool_contracts),
        parallel_tool_calls=prompt.parallel_tool_calls,
    )

这段伪代码把“合同内容”和“合同放在哪里”分开。两条路径序列化的是同一批模型可见合同,差异只在 请求适配层。若编码失败,请求在发送网络数据之前就终止,不会出现 Provider 收到半截工具列表的 状态。

模型交回参数后,合同要再执行一次

模型返回 Function Call 时,Runtime 先保留三个关键事实:规范工具名、调用 ID、原始参数字符串。 此时不会由中央 Router 把所有工具参数解析成统一大对象,因为不同 Handler 需要不同业务类型。

接下来的职责分成三步:

  1. 用当前 Step 的执行表按规范名称定位 Handler;
  2. Handler 把原始参数反序列化成自己的业务参数;
  3. 只有解析和业务前置检查都成功,才进入实际工具逻辑。
def dispatch_function_call(call: ModelFunctionCall, plan: ToolPlan) -> ToolResult:
    if call.step_id != plan.step_id:
        return ToolResult.error(call.id, "调用与工具计划快照不匹配")

    handler = plan.dispatch.find(call.canonical_name)
    if handler is None:
        return ToolResult.error(call.id, "当前 Step 不支持该工具")

    try:
        arguments = handler.decode_arguments(call.raw_arguments)
    except ArgumentError as error:
        return ToolResult.feedback_to_model(
            call_id=call.id,
            message=f"工具参数无法解析:{error}",
        )

    return handler.run(arguments, call_id=call.id)

这不是说生产实现一定显式比较 step_id;生产系统通过让调用持有原 Step 的计划快照来保护同一 不变量。伪代码把隐含在所有权关系里的约束写成判断,是为了让设计更容易看清,而不是声称源码中 存在这一行。

参数错误被转换成模型可见反馈,而不是伪装成工具成功。模型可以在下一轮重新生成参数。至于一个 工具结果怎样与原调用配对、怎样写入 History、取消时如何补齐 Aborted Output,将在工具生命周期 章节展开。

失败点不是一类错误

Function Tool 合同阶段本身没有文件或进程副作用,因此不会涉及审批回滚。它的失败主要发生在 “合同建立”“本轮暴露”“请求编码”和“返回解析”四个边界:

失败位置已完成的工作模型看到什么恢复方式
外部 Schema 无法转成内部子集尚未形成有效合同通常看不到该工具修正来源定义,在后续 Step 重建
根 Schema 只能生成 null正规化完成,最终检查失败不收到这个无有效参数的合同改成对象、标量或合法联合类型
Schema 超过预算可能已删除说明、定义或深层结构看到压缩后的合同拆小工具、减少嵌套或谨慎采用可信路径
名称重复候选实现已经收集只看到一个规范合同消除重名并明确优先级
当前策略不直接暴露执行能力可能仍已注册初始列表看不到经搜索或间接模式发现,或调整策略
合同 JSON 编码失败请求还没有发送没有这次模型采样修复合同数据并重建请求
Lite 顶层没有 tools工具已进入 developer 输入项正常看到工具调试器检查正确承载位置
返回参数不满足业务类型调用意图和调用 ID 已存在,业务尚未执行收到可修正的参数错误模型重新提交参数

Schema 压缩不是错误,却可能制造质量退化。删除 description 后,字段类型仍在,但单位、格式和 适用条件可能消失。遇到这种情况,首先应该重新设计工具边界,而不是继续扩大压缩阈值。

这套设计选择了哪些代价

用类型化内部 Schema,换取稳定线格式

内部 Schema 子集让内建工具能稳定序列化和测试,也让客户端知道哪些关键字确实受支持。代价是 外部 Schema 必须被降低到这个子集,复杂方言可能丢失信息。

完全保留原始 JSON 会更简单,却把兼容错误推迟到 Provider,也难以统一删除不可达定义和控制 Prompt 体积。

把合同和 Handler 放在同一个实现包,换取版本一致性

这种设计降低了说明书与执行器分别升级造成的漂移。代价是测试替身也要同时提供合同与行为,工具 接口比一个普通回调更重。

每个 Step 重建工具计划,换取动态能力正确性

环境、插件、MCP 和模式可能在 Turn 内变化。逐 Step 重建允许下一次采样采用新能力;保留原 Step 执行表又保证已经发出的调用不被新版本劫持。代价是规划和 Schema 处理会重复发生,因此需要缓存、 Raw JSON 共享和大 Schema 控制。

在 Handler 边界解析参数,换取中央 Router 的可扩展性

中央 Router 只处理规范名称、调用 ID 和原始参数,不需要为每个新工具增加参数类型。代价是每个 Handler 都必须实现严格的输入转换,并把错误转换成一致的模型反馈。

怎样验证这套设计没有被实现细节破坏

对应测试不需要逐行复述源码,而应分别锁住这些行为:

行为合同测试应观察什么
Function Tool 线格式稳定类型、名称、说明、严格度和参数位于正确层级
内部输出 Schema 不外泄普通工具 JSON 中不存在内部结果定义
延迟字段按需出现未启用时字段缺席,而不是发送 false
Raw JSON 优化不改变语义解码后与普通 JSON 值路径完全一致
无效根 Schema 被拒绝只能生成 null 的合同无法注册
大 Schema 按顺序压缩信息充足时不提前执行更有损的 Pass
Lite 使用正确承载位置顶层工具缺席,但首个 developer 工具项完整
Handler 保留最终校验错误参数不会进入业务副作用,而是反馈给模型

配套 Mini Codex 只复刻普通 Function Tool 的线合同,并用测试确认输入 Schema 位于 parameters、 内部输出 Schema 不进入普通 JSON。它没有复刻外部 Schema 编译、Namespace、Tool Search、Code Mode、Responses Lite 或 Raw JSON 优化,因此不能被当作生产实现的等价替代。

到这里,Function Tool 的链条才算闭合

Function Tool 不是可执行函数的远程副本,而是一次 Step 中生成的模型合同。它与执行表来自同一 快照;外部 Schema 先经过兼容、裁剪和按需压缩;合同根据 API 模式放进不同请求位置;模型返回的 原始参数最终仍由具体 Handler 校验。

这条链可以压成一句话:Schema 规定模型应该交回什么,Handler 验证模型实际交回了什么,同一 Step 的执行表则保证这份调用仍由当初那份合同对应的实现处理。

阅读导航

返回第六章总览 · 下一节:6.2 ToolSpec::Namespace 的合并与 Provider 能力门控

评论


← 返回文章列表