来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)
本内容改编自小山学堂《学 AI 产品,从入门到精通》,为二次演绎配音版
本集属于技术侧(S2)模块 T3「解剖 OpenAI Codex:把安全观写进类型系统」,取材自 2 节课:
两节课讲的是同一件事的两个面:
共同的思路是:把外部承诺和内部实现拆开,各自用最合适的结构表达。
一句话概括:规划函数决定广告什么,注册表决定能 dispatch 什么,暴露态决定直接、延迟还是隐藏,StepContext 把它们冻在采样边界。
读完本集(听完音频),你应该能够:
| 能力 | 具体表现 |
|---|---|
| 区分 step 与 turn | 知道冻结单位是 step(一次采样),一个 turn 里可有多次采样 |
| 解释为什么冻结 | 说清「广告一份、执行另一份」为何是工具表最常见的翻车 |
| 复述规划顺序 | 能按源码顺序说出工具清单的组装流水线 |
| 按身份裁剪 | 说出 Guardian / 非 Managed / Managed 有环境 / 普通会话四种情形的可见表 |
| 区分三份集合 | Direct / Deferred / Hidden 各自的含义与用途 |
| 判断 tool_search 挂载 | 知道需要两个条件同时成立才会挂 |
| 处理撞名 | 说清 mcp__ 前缀 → 十二位哈希后缀 → 核心名优先的消歧链 |
| 拆分请求侧与执行侧 | 并行旗(对模型)与本地并发表(宿主)各管一段 |
| 复述锁的语义 | 单元值闸门、spawn → 等就绪 → 拿锁、就绪等待在锁外 |
| 解释公平性代价 | fair / write-preferring 换来写者不饿死,代价是后来的读者被隔开 |
| 区分顺序 | 「何时开工」与「结果按什么顺序入账」是两条独立队列 |
| 选型判断 | 能用「工具来源是静态还是动态」判断用基表还是中心规划函数 |
你刚接上 MCP filesystem,模型这一轮还在跑。
| 冻结粒度 | 后果 |
|---|---|
| 按整轮用户对话冻结 | 新连上的服务器要等下一条用户消息才进菜单(太慢) |
| 每调一次工具冻结一次 | 同一次采样里并行的两次调用可能看见两份不同的表(不一致) |
广告一份、执行另一份,或者执行到一半表变了,是工具表最常见的翻车。
Codex 把这一次采样的模型、审批、环境、MCP 绑定和已算完的 tool_router 一起放进 StepContext。注释写明它是 request-scoped:
冻结单位是 step,不是 turn。 一个 turn 里可以有多次采样,每次重建一份 StepContext。
掉线的影响:MCP 服务器在采样中途掉线,改不了已经算完的表。下一次采样会再走 mcp_runtime_for_step,绑定可能复用,也可能换成空绑定——那时那些名字才会消失。
build_tool_router)
1. 解析这一步的 MCP 绑定
2. 采集 MCP 目录,交给规划函数
3. 按身份决定是否走完整来源
4. 登记 shell、资源、实用、协作
5. 追加 MCP 并套上暴露策略
6. 有 deferred 才挂 tool_search
7. 只把 is_direct 的 spec 发给模型
8. 冻进 StepContext.tool_router
9. build_prompt 读取可见表
这套设计的最小形态就是一个函数:
输入:开关、MCP 目录、身份
输出:可见表 + 可执行表
时机:请求开始时算一次,算完就冻
换任何语言都一样,不依赖任何框架。可见和可执行共用一份快照,下一轮再吸收新连接。
审批模型如果看见工作模型那整张 MCP 和协作工具表,它就能去 spawn_agent、读外部资源,审批本身被绕开。
先注册全表再过滤是错的:只要漏一条分支,就会把不该给的名字送出去。
| 身份 / 条件 | 可见表 |
|---|---|
Guardian 评审员(标签必须恰好是 guardian) | 走评审专用路径 |
| 权限档案不是 Managed | 函数直接返回,注册表为空 |
| Managed 且有环境 | 最多三件:exec_command、write_stdin、可选 view_image |
| 普通会话 | shell、MCP 资源、实用工具、协作四组,再追加 MCP、扩展、动态工具 |
读盘没有一等入口:handlers目录里没有ReadFileHandler。模型要读文件,走exec_command、MCP filesystem,或view_image内部的 exec-server API。
默认关上门这件事,发生在评审员、环境缺失、特性关闭这几处——而不是散落在每个工具的 if 里面。
read_file,不消歧就会撞名| 暴露态 | 含义 |
|---|---|
| Direct | 进初始表 |
| Deferred | 只留给 tool_search |
| Hidden | 只留给 dispatch(模型看不见,但可执行) |
搜索开着时,MCP 工具默认 Deferred,不进初始可见表。
tool_search 的挂载条件(两个都要成立)search_info 的工具没有 deferred 的东西,tool_search 不会进表——加了也没东西可搜。
默认加 mcp__ 前缀
→ sanitize 后仍撞车 → 再加 12 位哈希后缀
外部工具撞到已占用的核心名 → 直接跳过(核心名保留,外部让路)
注册了,还不等于这一轮发给了模型。
可见、可检索、可执行是三份集合。token 预算紧就推迟发现,核心名保留、外部让路。
注意仓库根AGENTS.md仍写着mcp_connection_manager.rs,当前树里没有这个文件——消歧实际在tools.rs。文档与代码漂移本身就是一类风险。
| Codex | DSH | Claude Code | |
|---|---|---|---|
| 组织方式 | 中心规划函数 | 插件各自投递 schema | 写死的基表数组 |
| 排序 | 规划顺序 | 部署配置 toolOrder + 保留标记 | 过滤 deny → 内置/MCP 各自排序 → 拼接 |
| 撞名 | mcp__ 前缀 + 哈希 | — | 内置在前,内置赢 |
| 读盘入口 | 无一等入口(走 shell/MCP/内部 API) | 一等工具 read | FileReadTool 一等成员 |
| 数清工具数 | 必须走完规划流程 | — | 打开一个文件就能数完 |
DSH 要点:未点名的工具插在保留标记 <unlisted-tools> 处按字典序;漏写这个标记,装配期直接失败。
Claude Code 要点:注释写明这样排是为了 prompt cache——MCP 插到内置中间,后面的 cache key 会全失效。
| 条件 | 建议 |
|---|---|
| 工具来源相对固定、数量不大 | 写死一张表更好维护,还能顺手保住提示缓存 |
| 工具来源随外部连接动态变化、需按审批身份裁剪 | 中心规划函数,每次请求开始时算一遍 |
两种做法没有绝对优劣,只有一个决策点:你的工具来源是静态的还是动态的。答错这一题,后面每一行代码都会别扭。
你让模型同时读 src/main.rs、src/lib.rs,再打一处补丁。屏幕上两份读几乎同时出进度,打补丁那一下却停了一拍。
| 极端做法 | 后果 |
|---|---|
| 把「可以并行」理解成叠着跑 | 两个 apply_patch 一起改共享的 diff 记账,账会乱 |
| 全部首尾相接 | 两个读文件也要排队,白白多耗一轮墙钟 |
请求侧那面旗只回答「模型愿不愿意一次发多个盒子」,回答不了盒子落地时谁能重叠。
false,采样主路径 build_prompt 把它写成 truetrue,为的是与主采样请求形状对齐(多路复用会逐字段对比,包括这面旗)| 工具 | supports_parallel_tool_calls |
|---|---|
| 默认 | false(漏写覆盖 → 走写锁) |
exec_command / view_image / tool_search | 覆盖成 true |
apply_patch | 不覆盖(走写锁) |
| 路由查不到的名字 | 当 false |
| 暴露态为 Hidden | handler 自称 true 也没用 |
| MCP | 还需服务器开关或只读提示,缺省串行 |
模型只看见旗是真还是假。它看不见谁能并行——工具清单也不会因为某工具要拿写锁而少一项。
一次采样共用一把 tokio::sync::RwLock<()>,锁保护的值是单元类型,里面没有业务数据,只当闸门。
先 spawn → 可选地等就绪 → 最后才拿锁
能并行 → read() 否则 → write()
就绪等待必须放在锁外面。 一个还没连上的 MCP 服务器,不会占着写锁让旁边已就绪的 exec_command 也卡住。
这把锁保护的值是单元类型,业务数据的一致性由别处负责,它只回答「此刻有没有独占者」。
把这个理解偏了,就会想在锁里塞状态——然后锁的生命周期和业务的生命周期缠在一起,死锁排查会变成噩梦。
锁的优先级是 fair / write-preferring:
已排队的写请求没拿到、没释放之前,后面的读锁不会发下去。
具体例子:view_image 还在跑,apply_patch 已在门外排队 → 此时再来的 exec_command 明明可以和 view_image 重叠,也必须排在补丁后面。
| 说明 | |
|---|---|
| 换来 | 写者不会饿死 |
| 代价 | 后来的读者被写者隔开 |
若换成标准库那把 RwLock,优先级依赖操作系统,写者有机会被饿死。
exec_command 无论跑 ls 还是 rm,都走读锁apply_patch 无论补丁多大,都走写锁餐厅类比:多人可同时看菜单(读锁),后厨只准一人进(写锁)。业务代码只问一句——这一刻有没有独占者。
| Codex | DSH | Claude Code | |
|---|---|---|---|
| 分类依据 | 工具级布尔(默认 false) | 按参数 isConcurrencySafe(args) | 按参数 isConcurrencySafe → BashTool 交给 isReadOnly |
| 缺声明 | 走写锁 | 独占 | 默认 false |
| bash | exec_command 走读锁(写命令也读) | 整条独占 | 只读命令并行,写命令串行 |
| 分组 | 一把锁模拟屏障 | 连续并行编一组,独占单独成组当屏障 | 安全调用收成一批,不安全各自成批 |
| 上限 | 无(交给 OS) | 滚动池,默认 10 | 环境变量,解析失败时 10 |
闭合位置不同:Codex 闭合在默认值和 Hidden(对 shell 最放开);DSH 闭合在分类器(bash 全串行);Claude 夹在中间。
两个 exec_command 叠着跑,后发出的可能先跑完。若按完成顺序写历史,模型下一轮看到的结果顺序会和它当初发出的调用对不上。
做法:采样循环把 tool_future 按到达顺序推进 FuturesOrdered;drain 按插入顺序出队,再写入会话。
先发出的先入账,哪怕它其实更晚跑完。
| 顺序 | 规则 |
|---|---|
| 模型发出的顺序 | 调用列表原顺序 |
| 锁上实际执行的顺序 | 可以重叠、可以乱序 |
| 历史里写下的顺序 | 按发出顺序(保序队列) |
区分清楚:流内每收到一条 OutputItemDone 就建 future,那一套管的是何时开工;这里管的是开工之后谁能重叠以及结果按什么顺序入账。别混为一谈。
tool_search 的两个挂载条件是否都被检查openai/codex,commit 4f39251a01,核对日期 2026-08-22。build_tool_router,逻辑轨迹行号对应上述 commit。AGENTS.md 提到的 mcp_connection_manager.rs 在当前树中不存在,消歧位于 tools.rs。规划函数决定广告什么,注册表决定能 dispatch 什么,暴露态决定直接、延迟还是隐藏,StepContext 把它们冻在采样边界。
对模型说可以并行,是请求侧的旗;谁能叠着跑看工具有没有改默认值,Hidden 和未知名字走独占;跑完之后,历史按发出顺序入账。三套顺序可以不一致。
来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)
本内容改编自小山学堂《学 AI 产品,从入门到精通》,为二次演绎配音版