刚开源的 Codex 到底是怎么“干活“的?把它的 Harness 拆开一看,全是循环

先说一个反直觉的事:大模型自己根本不会干活。
你让它"帮我修这个 bug",它不会真的去翻代码、跑测试、改文件。它只会做一件事——吐字。一个字一个字往外蹦。
那 GitHub 的 Codex 是怎么做到"真的去干活"的?秘密就在一个叫 Harness 的东西里。今天我就把它拆开给你看。
文章目录
先泼盆冷水:模型只是个"嘴"
很多人以为 AI 编程助手是"一个很聪明的大脑,自己会动手"。
真相是:模型就是个只会说话的嘴。它连磁盘都碰不到,更别说执行命令、改文件了。
那"干活"的到底是谁?
是一个包在模型外面的循环。这个循环负责:
- 把该让模型知道的东西,组装好喂给它
- 把模型能用的工具摆到它面前
- 让模型"说"它想干什么
- 替它真的去执行
- 把执行结果再喂回去
- 循环,直到模型说"完"
这个循环,在 Codex 的代码里就叫 Harness(马具、挽具)。
名字起得挺贴切——马不会自己拉车,得套上挽具,由人牵着缰绳走。模型不会自己干活,得套上 Harness,由循环推着它一步步走。
一句话:Harness = 包在模型外面的 agent 循环。
它不是单独的组件,而是 RegularTask → run_turn → run_sampling_request 这么一串循环逻辑,主要实现在 codex-rs/core 里。
一个问题进来,先走五层
你敲下一句话,它不会直接砸到模型脸上。中间隔了五层,像餐厅点菜一样,一层层往下传:

- 输入层:TUI / CLI / App Server 客户端——你在这里打字
- 路由层:App Server 的 JSON-RPC,决定是开新一轮(
turn/start)还是插话(turn/steer) - 调度层:Core 的
submission_loop,按指令分发 - Harness:
RegularTask+run_turn,组上下文、暴露工具、采样、跑工具——核心在这 - 事件层:Core 事件 → App Server 通知 → UI 渲染
每一层各管一摊,解耦得挺干净。这也是为什么你能在 TUI、CLI、App Server 三种界面里用同一个 Codex 内核——换的只是第一层的"点菜窗口"。
一个问题怎么钻进 Core
从 TUI 出发
你在聊天框里回车,这一下会变成一个 AppCommand(通常是 UserTurn),然后 ChatWidget.submit_op 发出一个 AppEvent::CodexOp(...)。
路由:开新局,还是插话?
这里有个很贴心的设计(thread_routing.rs):
- 如果现在正有一轮回答在跑,你新输入的话会被并进去(
turn_steer)——就像你打断别人说话,对方接着你的话茬继续。 - 如果空闲,就开新一轮(
turn_start)。
App Server 做"翻译官"
turn_processor.rs 干了几件事:校验输入长度、把 v2 的输入项翻译成 Core 认识的 UserInput、组装环境/权限/模型覆盖项,最后调用 CodexThread.start_or_steer_turn(...)。
Core 的队列:先排队,不阻塞
CodexThread 把请求包成 Op::TurnInput,丢进 session 的提交通道。submission_loop 按 Op 分发,调 turn_input::handle。
注意,这个函数只做路由判断,绝不傻等采样。它给你三个答案之一:
| 结果 | 含义 |
|---|---|
Started | 线程空闲,创建 TurnContext,spawn_task(RegularTask) 开跑 |
Steered | 已有活跃 turn,把输入追加进去 |
NotSubmitted | 被拒了(turn id 不对、这轮不能插话等) |
Harness 到底在循环什么

说白了就是下面这几步,反复转:
- 组装上下文——模型能看到的:instructions、world state、AGENTS.md、skills、历史对话
- 暴露工具——这一步给模型摆出它能调用的工具
- 发起一次流式请求(Responses API)
- 执行工具调用(可能要过审批、进沙箱)
- 把工具结果写回 history
- 再采样,直到模型结束,然后发
TurnComplete
几个关键函数,先混个脸熟:
| 函数 | 文件 | 干嘛的 |
|---|---|---|
RegularTask.run | tasks/regular.rs | 外层 turn 任务 |
run_turn | session/turn.rs | 内层 agent 循环(主角) |
capture_step_context_... | session/mod.rs | 冻结工具/环境/AGENTS.md |
build_prompt | session/turn.rs | 组装模型请求 |
run_sampling_request | session/turn.rs | 一次采样(带重试) |
handle_output_item_done | stream_events_utils.rs | 区分文本和工具调用 |
ToolCallRuntime.handle_tool_call | tools/parallel.rs | 真正执行工具 |
build_tool_router | tools/spec_plan.rs | 组装工具表 |
外层循环:RegularTask,就管三件事
RegularTask.run 是个很"懒"的壳,只干三件事:
- 立刻发
TurnStarted,让 UI 先渲染起来,别让用户干等 - 尽量复用启动时预热好的
ModelClientSession(WebSocket / sticky routing,省得每次重新握手) run_turn返回后,如果队列里还排着用户输入,就再跑一轮
伪代码长这样:
发 TurnStarted
复用预热好的 ModelClientSession
loop:
last_agent_message = run_turn(...)
if input_queue 没有排队输入:
return last_agent_message
# 否则,把排队的用户消息再喂一轮 run_turn
第一次采样前,它偷偷干了七件事
很多人以为"采样"就是直接把问题丢给模型。其实在第一次真正采样前,run_turn 先做了一堆铺垫(都在 session/turn.rs):
| 步骤 | 函数 | 目的 |
|---|---|---|
| 排空迟到的 hook | drain_async_hook_results | 把上一轮结束后才完成的 hook 结果入账 |
| 预压缩 | run_pre_sampling_compact | 下次请求可能超窗时,先压缩上下文 |
| 解析 MCP/插件 | required_mcp_servers_for_input | 看 @plugin 这类提及,决定拉哪些 MCP |
| 冻结本步视图 | capture_step_context... | 工具、环境、AGENTS.md、能力用同一份快照 |
| 写 world state | record_context_updates... | 让 cwd、权限、模型、环境对模型可见 |
| Skills/plugins | build_skills_and_plugins | 注入技能说明和显式启用的 connector |
| Hooks + 记录输入 | run_hooks_and_record_inputs | hook 能拦截输入;通过了才把用户文本写进 history |
这里有个细节值得注意:capture_step_context 冻结的是"本步"的快照。也就是说,这一轮里组上下文、暴露工具、派发工具,用的都是同一份视图,不会出现"前面说有一套工具、后面真调用时又变了"的错位。
工具怎么来的?build_tool_router 从几个来源拼:核心工具(shell、apply_patch、多 agent 等)、MCP 工具、扩展工具、动态工具。还有个细节:Guardian review 这一轮不会暴露 MCP 工具——审核的时候不给它太多武器,挺合理。
模型到底"看到"了什么
模型拿到的不是原始聊天文本,而是一个组装好的 Prompt:
Prompt {
input // history.for_prompt(...)
tools // tool_router.model_visible_specs()
parallel_tool_calls // true
base_instructions // session 系统提示
output_schema // 可选的结构化输出
}
那个 input,是 history.for_prompt(...) 合并出来的"大杂烩":
- developer / user instructions
- world state(工作区、权限、模型、环境)
- AGENTS.md、skills、plugin 说明
- 历史对话
- 本轮用户问题
- 之前的 tool call / tool result
base_instructions 来自 Session.get_base_instructions(),跟 history 里的 contextual fragments 是分开的。
一句话:模型看到的,是一份被精心拼好的"上下文全家桶",而不是你打的那句话本身。
内层循环:ReAct,采样→工具→再采样
run_turn 里有个 loop,每一圈就是一次采样请求。这是整个 Harness 的心脏:

这其实就是经典的 ReAct(Reason + Act) 循环:
- 模型思考——它先"说"出想法(流式吐字)
- 调用工具——它提出要调用某个工具
- 结果回灌——Harness 执行完,把结果写回 history
- 回到第 1 步——带着结果再让模型想一次
流怎么被处理
try_run_sampling_request 读 SSE 流,按事件分门别类:
| 模型事件 | Harness 动作 |
|---|---|
| 文本/推理增量 | 立刻发 AgentMessageContentDelta / ReasoningContentDelta,边想边给你看 |
| 消息/推理完成 | ItemStarted / ItemCompleted,写入 history |
| 工具调用完成 | 设 needs_follow_up,排队一个 tool future |
| 流结束但工具没跑完 | 等工具跑完,写回结果,再采样 |
工具到底怎么执行
stream_events_utils.rs 里三步走:先把 tool call item 写进 history → ToolCallRuntime.handle_tool_call 执行 handler → 设 needs_follow_up = true。
tools/parallel.rs 里有个挺讲究的并发控制:
- 可并行工具(比如多个只读查询)拿读锁,能同时跑
- 互斥工具(比如改文件的 shell)拿写锁,串行执行
执行过程中还可能卡在用户审批上(exec / patch / permissions)。结果变成 FunctionCallOutput 写回 history,下一圈采样就能看到。
关键点:模型自己永远不碰磁盘。 所有副作用都走
ToolRouter。这层隔离,就是安全感的来源。
一圈采样之后,看三个标志
run_turn 会检查三个东西:
model_needs_follow_up——还有工具没跑完,或子 agent 往 mailbox 投了消息has_pending_input——模型跑着时你又打了一句(steer)token_limit_reached/ 新 context window——要不要在 turn 中间压缩
然后分情况:
- 要跟进且快超窗 →
run_auto_compact,再continue - 只要跟进 → 带着 history 里的工具结果
continue - 模型说完了 → 跑
run_turn_stop_hooks。hook 可以should_block并塞一个 continuation prompt,强制再采样;否则内层循环 break。
之后 RegularTask 再看一遍 input queue,决定要不要再跑一轮。
输出是怎么"飞"回你屏幕的
有个体验细节:流式文字在采样过程中就发出去了,UI 根本不用等 TurnComplete。所以你能看到模型"边想边打",而不是憋半天一次性吐出来。
任务收尾时,spawn_task(tasks/mod.rs)会发 TurnComplete(带 last_agent_message)或 TurnAborted。
事件路径:
Session.send_event/send_event_raw_with_persistence- 按策略持久化 rollout
- App Server
conversation.next_event() apply_bespoke_event_handlingServerNotification(TurnStarted、item 增量、TurnCompleted等)- TUI 渲染
一个完整例子:它到底经历了什么
你问:「这个函数为什么 panic?」
- TUI / App Server 把它映射成
TurnInput::UserInput - Core 返回
Started,RegularTask发TurnStarted - 刷新 AGENTS.md、MCP、工具表,冻结进
StepContext - 把 world state、skills、用户问题写进 history
- 把
history + base_instructions + tools发给模型 - 模型先流式输出「我先看代码」,再调用
shell/ 搜索 / 读文件等工具 - Harness 执行工具(可能先审批),把结果写回 history
- 再采样,模型写出解释(以 delta 流式发出)
- Stop hooks 放行 →
TurnComplete - UI 早就把流式文字展示出来了,
TurnComplete正式收尾
一句话总结
Codex 的 Harness =
RegularTask包一层 turn,run_turn反复做「组 Prompt → 流式采样 → 执行工具 / compact / hook → 再采样」,直到模型不再需要跟进。
模型负责"想",Harness 负责"做"。一个只会吐字,一个真正动手。两者加起来,才是你看到的那个"会干活的 AI"。
附录:关键文件速查
| 路径 | 职责 |
|---|---|
tui/src/chatwidget.rs | 接收用户提交 |
tui/src/app/thread_routing.rs | turn/start 与 turn/steer |
app-server/src/request_processors/turn_processor.rs | App Server turn API |
core/src/codex_thread.rs | 线程侧 submit / start-or-steer |
core/src/session/handlers.rs | submission_loop |
core/src/session/turn_input.rs | Start / steer / 拒绝 |
core/src/tasks/regular.rs | 外层 harness 任务 |
core/src/tasks/mod.rs | spawn_task、TurnComplete |
core/src/session/turn.rs | run_turn、采样、prompt |
core/src/session/mod.rs | Step context、world state、事件 |
core/src/session/turn_context.rs | TurnContext |
core/src/stream_events_utils.rs | 流 item → 文本或工具 |
core/src/tools/spec_plan.rs | 工具表 |
core/src/tools/parallel.rs | 并行 / 串行工具运行时 |
app-server/src/bespoke_event_handling.rs | Core 事件 → 客户端通知 |
参考代码:https://github.com/openai/codex
如果你觉得这篇对你有用,点个赞、转给身边写代码的朋友。下一篇可以聊聊 Codex 的上下文压缩(compact)到底是怎么"瘦身"的——想看的人多就安排。
更多推荐



所有评论(0)