拆解 Codex:一个桌面 Agent 到底由哪些核心部分组成?
写在前面
大家好,我是三雒。
最近一段时间在做一个桌面的Agent项目,一直在研究 Codex 的实现,也尝试把它的本地工具能力复用到自己的项目里。刚开始看代码时,我脑子里有一个很朴素的理解:Codex 不就是一个会调用 工具的大模型吗?
但真正顺着源码往下追,很快就会发现这个理解太薄了。
模型确实负责推理,也会生成 Tool Call,但它并不天然拥有当前工作目录、长进程和文件权限这些操作系统状态。真正把工作区信息提供给模型,并保证命令在正确目录执行的,是模型外面的 Agent Harness。一次持续几分钟甚至几小时的任务,也不是靠模型单次回答完成的。
这一篇是"拆解 Codex"系列的第一篇,我们先不钻进某个 Tool 的参数,也不急着分析每个 Rust 模块,而是先回答一个最基础的问题:
当用户输入一句话时,Codex 为什么能够连续读代码、运行命令、修改文件、执行测试,最后再给出结论?
本文分析基于本地 codex 仓库的 f3465047d1ee 版本。
先从一个真实任务开始
假设我们给 Codex 一个很常见的任务:
请修复 APP-1427:登录成功后偶发跳回登录页。Bug 单中附有复现步骤和客户端日志,请定位根因、完成修复并验证。
如果只是普通聊天模型,它最多根据我们贴进去的 Bug 描述和代码给出修改建议。但 Coding Agent 的关键,是能把这句话转换成一个持续执行的工作流。
- 模型识别出这是一个 Bug 修复任务,根据当前可见的 Skill 描述选择并加载对应 Skill;
- Skill 的操作规则进入上下文,告诉模型这类任务通常需要读取 Bug 单、获取日志、定位代码并完成验证;
- 模型先生成读取 Bug 单和获取日志的 Tool Call,ToolRouter 找到对应 Handler,由工具真正执行这些动作;
- 工具返回 Tool Result,Agent Loop 将结果写入 Conversation History,并带着更新后的上下文再次请求模型;
- 模型分析日志内容,形成新的排查线索,再调用搜索工具定位相关代码;工具只负责执行搜索,并把代码片段交还给模型;
- 模型判断证据足够后生成修改方案,调用 Patch 工具落盘,再调用命令工具运行测试;
- 测试结果再次回到 Agent Loop。失败时,模型根据新输出继续调整;通过后,模型才返回最终结论。
这条链路里,模型负责理解任务、分析结果和决定下一步;工具负责读取、搜索、修改和执行;Agent Loop 则把两者连接起来,让一次 Tool Result 成为下一次模型判断的输入。
在第一轮模型判断之前,对当前工作目录生效的 AGENTS.md 已经在会话或 Turn 的上下文构建阶段被发现并加入指令。只有工作目录或规则作用域发生变化时,系统才需要刷新适用的规则。
说白了,这不是一次问答,而是模型在项目规则约束下,借助 Skill 和工具不断观察、行动、再观察的闭环。
Codex 的整体结构
从职责上看,可以把 Codex 分成三层:
- 最上面是 CLI、IDE、桌面应用等客户端;
- 中间是负责推理、状态和工具编排的 Agent Harness;
- 最下面是本地操作系统、工作区以及通过 MCP 接入的外部服务。

如果继续拆开中间的 Harness,最核心的是五部分:
- Agent Loop:驱动模型和工具持续循环;
- 上下文与状态:保存任务进度,并构造每次模型真正看到的输入;
- Tool 系统:把模型给出的工具意图路由到正确处理器;
- 本地执行 Runtime:真正运行命令、修改文件并管理进程;
- 权限与安全:在动作落地前约束文件、网络和提权边界。
AGENTS.md、Skills、Plugins 和 MCP 并不是另一套 Agent Loop。它们分别从"工作规则"和"外部能力"两个方向扩展这套 Harness。
下面逐个看。
1. Agent Loop:真正让 Agent 动起来的发动机
Codex 当前的主循环入口位于:
text
codex-rs/core/src/session/turn.rs
其中 run_turn 的源码注释已经把基础逻辑说得很清楚:模型会返回 Assistant Message 或 Function Call;如果返回 Function Call,Codex 执行工具,并在下一次请求中把结果交还给模型;如果模型只返回最终消息,这个 Turn 就可以结束。
抽掉错误处理、事件上报和上下文压缩后,它的骨架大致可以理解成:
rust
loop {
// 1. 构造这一次模型看到的上下文
let step_context = session.capture_step_context(turn_context).await;
let history = session.clone_history().await;
// 2. 请求模型,并持续消费流式响应
let mut stream = request_model(
step_context,
history.for_prompt(...),
).await?;
let mut in_flight = FuturesOrdered::new();
let mut needs_follow_up = false;
while let Some(event) = stream.next().await {
if let ResponseEvent::OutputItemDone(item) = event? {
match ToolRouter::build_tool_call(item.clone())? {
// 3. 模型返回 Tool Call:交给 Runtime 执行
Some(call) => {
in_flight.push_back(Box::pin(
tool_runtime
.clone()
.handle_tool_call(call, cancellation_token.child_token())
));
needs_follow_up = true;
}
// 普通 Message 或 Reasoning:记录为会话 Item
None => session
.record_conversation_items(turn_context, &[item])
.await,
}
}
}
// 4. 等待工具完成,并把 Tool Result 写回 History
while let Some(tool_result) = in_flight.next().await {
session
.record_conversation_items(turn_context, &[tool_result?.into()])
.await;
}
// 5. 有 Tool Result 时进入下一轮,让模型继续判断
if !needs_follow_up {
break;
}
}
真实源码没有把这些动作全部写在 run_turn 同一层:run_sampling_request 构造 ToolRouter 和 ToolCallRuntime,try_run_sampling_request 消费模型流,handle_output_item_done 识别并调度 Tool Call,最后由 drain_in_flight 等待工具完成并把 Tool Result 写回 Conversation History。
除此之外,真实实现还要处理用户中途追加的消息、重试、取消、Hooks、Context Window、自动 Compaction,以及可能并行返回的多个 Tool Call。
但最稳定的核心没有变:
text
模型判断 → 执行动作 → 观察结果 → 再次判断
所以,模型负责产生"下一步应该做什么",Agent Loop 负责让这一步真的发生,并把结果重新送回模型。两者组合起来,才会出现我们看到的持续工作能力。
2. 上下文与状态:模型每一步到底看见了什么
问题来了,第二次请求模型时,Codex 应该发送什么?
显然不能只发送最初那句"修复 APP-1427"。模型还需要知道:
- 前面已经搜索过哪些文件;
- 工具返回了什么结果;
- 当前工作目录和可用环境;
- 项目中的
AGENTS.md规则; - 当前 Turn 启用了哪些 Skills、Plugins 和 Connectors;
- 文件和权限状态是否发生变化;
- 用户有没有在运行过程中追加新要求。
在当前实现里,这部分不是一个简单的 Vec<Message>。
运行层至少可以看到三个不同生命周期的对象:
Session:承载整个线程级的服务、历史和活动任务;TurnContext:固定一轮任务使用的模型、权限、模式和配置;StepContext:固定某一次 Sampling 看到的环境、MCP Tool 快照和AGENTS.md状态。
这里 StepContext 很关键。一次 Turn 可能包含很多次模型请求,而工作区、可用环境、MCP 工具列表在期间可能变化。Codex 会为一次具体的 Sampling 捕获一致的请求视图,保证"展示给模型的工具"和"随后真正执行的工具"来自同一份上下文。
然后,Conversation History 会经过 for_prompt(...) 转换成模型输入。随着任务持续运行,Tool Output、Assistant Message 和用户追加信息不断进入历史。
上下文快到窗口上限时,run_turn 还会触发自动 Compaction,把冗长历史压缩成后续仍可继续工作的状态。
因此,上下文工程并不是"把整个仓库全部塞进 Prompt"。更准确地说,它是在每个决策点构造一个有边界、可复现、对当前动作足够的信息快照。
3. Tool 系统:模型意图如何找到真正的执行器
模型决定运行命令时,返回的不是一段直接交给操作系统的代码,而是结构化 Tool Call,例如:
json
{
"name": "exec_command",
"arguments": {
"cmd": "npm test",
"workdir": "/workspace/project"
}
}
Tool 系统需要完成两件不同的事情:
- 告诉模型当前有哪些工具、每个工具接受什么参数;
- 模型真的调用后,找到对应 Runtime 执行。
当前源码中的 ToolRouter 很直接地体现了这两个世界:
rust
pub struct ToolRouter {
registry: ToolRegistry,
model_visible_specs: Vec<ToolSpec>,
}
model_visible_specs 是模型能看见的契约,registry 则保存真正可以执行这些契约的 Runtime。
在每次 Sampling 前,built_tools(...) 会根据当前 StepContext 重新构建工具视图。这里合并的不只是 Codex 内置工具,还可能包括:
- MCP Tools;
- Plugins 和 Connectors;
- Dynamic Tools;
- Extension Tool Executors;
- Tool Search 动态加载的能力。
模型返回结果后,ToolRouter 把 Response Item 转成内部 ToolCall,再构造 ToolInvocation。其中不仅有参数,还带着 Session、StepContext、取消信号、Call ID 和 Diff Tracker。
这也是为什么 Tool 不能简单理解成一个函数。
模型看到的是能力契约;Router 负责识别意图;Registry 负责找到 Runtime;Handler 才负责进入具体执行流程。
4. 本地 Runtime:Tool Call 最终怎样落到操作系统
到了这一层,模型的意图才真正变成系统动作。
以最常见的命令执行为例,exec_command 需要处理:
- 命令和 Shell 解析;
- 工作目录和环境选择;
- stdout、stderr 聚合;
- PTY 与非 PTY 模式;
- 短命令直接返回;
- 长命令保留进程会话;
- 超时、取消和进程树回收。
如果命令没有在当前等待窗口内结束,Codex 会返回一个可继续操作的 session_id。后续 write_stdin 并不会启动第二个 Shell,而是继续向原来的进程或 PTY 写入内容,并读取增量输出。
修改文件也是类似的。
apply_patch 不是让模型随便覆盖一个文件。它需要解析 Patch、核对上下文、验证目标路径,再把文件变化转换成可追踪的 Diff 和 Tool Result。失败时,错误会回到 Agent Loop,让模型重新读取文件或调整 Patch。
所以 Tool 和 Runtime 也不是一回事:
- Tool 解决"模型想做什么";
- Runtime 解决"这件事在本机怎么可靠完成"。
5. 权限与安全:能执行不代表应该执行
一个可以运行任意命令、读写本地文件的 Agent,如果只依靠 Prompt 约束,风险非常高。
Codex 把安全边界继续下沉到了执行层。当前 TurnContext 中就包含:
- Approval Policy;
- Permission Profile;
- 文件系统 Sandbox Policy;
- Network Sandbox Policy;
- 当前工作目录与可用环境。
命令执行前,系统会结合 Tool 请求、配置和当前权限判断:
- 是否可以直接执行;
- 是否只能在 Sandbox 中执行;
- 是否需要向用户申请额外权限;
- 是否应该直接拒绝。
不同平台的底层实现并不相同。macOS 使用 Seatbelt;Linux 当前主要通过 Bubblewrap 构造文件系统沙箱,并配合 Seccomp 和 PR_SET_NO_NEW_PRIVS 限制进程与网络能力,Landlock 只保留为兼容旧版本的 Legacy 路径;Windows 则使用基于 Restricted Token 等机制的专用沙箱后端。但它们服务的是同一个目标:让模型无法仅凭自己的一句话越过系统授权。
安全规则不能只写在 System Prompt 里。Prompt 是给模型看的,Sandbox 才是模型绕不过去的执行边界。
一次完整任务是怎样跑完的
把上面的部分重新串起来,"修复 APP-1427 的登录跳转问题"会经过下面这条链路:

实际过程可以简化成这样:
- 客户端提交包含 Jira 链接和验收目标的 User Turn;
- Session 创建这一轮的
TurnContext,把适用的AGENTS.md规则和可用能力加入上下文; - Codex 捕获
StepContext,向模型提供历史、环境、工具以及可用 Skill 的描述; - 模型识别 Bug 修复意图,选择对应 Skill,并按 Skill 给出的流程发起 Tool Call;
- ToolRouter 将调用交给对应 Handler,由工具完成读取 Bug 单、获取日志或搜索代码等动作;
- Tool Result 被写回 Conversation History,Agent Loop 带着新结果再次请求模型;
- 模型分析返回的日志和代码,决定下一次 Tool Call;步骤 5~7 会持续循环;
- 证据足够后,模型调用 Patch 和命令工具完成修改与测试;
- 权限系统和 Sandbox 约束每一次真实操作;
- 测试结果同样返回 Agent Loop:失败则继续循环,通过后模型返回最终 Assistant Message;
- Turn 完成,客户端收到最终状态、Diff 和验证结果。
这里没有哪个单独模块能够称为"Codex",模型、Loop、Context、Tools、Runtime 和 Safety 缺一块,体验都会完全不同。
三个最容易混淆的概念
模型不等于 Agent
模型只负责根据当前输入生成下一步动作,Agent Loop 负责不断调用工具执行、观察和继续。模型本身无状态并不维护上下文,Agent 维护上下文并在每次请求时候把上下文发送给模型。
Tool 不等于执行器
Tool Spec 是模型可见的契约,ToolRouter 和 ToolRegistry 负责路由,真正操作系统的是对应 Handler 和 Runtime。
对话历史不等于上下文
模型输入除了对话,还包括项目规则、工作区状态、权限、工具定义、Skills、环境信息和经过压缩的历史。
写到最后
这一篇只是先建立对Agent的全局认识,相当于一张地图,沿着这张地图继续往下追,还有几个更值得弄清楚的问题:
- 模型每次只完成一次推理,Agent 为什么能够持续工作,直到任务真正结束?
- 一个持续几十分钟甚至更久的任务,Codex 怎样记住前面发生过什么,又如何决定每次该给模型看哪些信息?
- 当模型决定搜索代码、运行命令或修改文件时,这些意图究竟怎样变成操作系统中的真实动作?
- Agent 已经能够读写文件和执行命令,系统又如何判断哪些动作可以直接执行,哪些需要询问用户,哪些必须拒绝?
- 同一套 Agent 能力,又是怎样延伸到 MCP、桌面应用和真实浏览器中的?
这些问题,才是理解 Codex 从"会调用工具的模型"走向完整桌面 Agent 的关键。