主题色
250
壁纸模式
壁纸设置
特效设置
固定导航栏
字体选择
文章列表布局
5190 字
14 分钟
Codex 内置子命令深度解析:/compact、simplify Skill 与 /goal
2026-09-25

如果把 /compact、/simplify、/goal 都称作“内置子命令”,源码层面就会把三种完全不同的机制混在一起:/compact 是 TUI 识别的 slash command,/goal 是带持久化状态和自动续跑的线程目标,simplify 在 Codex CLI 0.155.1 中并不在 slash command 枚举里,而是一个通过 Skill 系统加载的 SKILL.md。

本文以 Codex CLI 0.155.1 的 rust-v0.155.1 源码为准,沿着“输入解析、事件路由、核心执行、状态持久化、模型上下文”五层追踪三者。读者需要能看懂少量 Rust 和异步调用;不需要先了解 Codex 的全部代码结构。

文件放在 posts/ai/agents-decoded/claude-code 是当前内容库的系列目录约定,本文分析对象是 Codex CLI,不把这些结论外推成 Anthropic Claude Code 的内部实现。

一、先把三个名字放回正确的层#

这一节先给出可以用源码验证的结论,后文再逐条下钻。

名称源码中的身份主要副作用是否改变当前会话的状态
/compactSlashCommand::Compact调用压缩任务,生成摘要并替换上下文历史是,替换模型可见的历史表示
simplifySkill 元数据和 SKILL.md把审查与简化规则注入模型上下文通常是模型通过工具修改工作区
/goalSlashCommand::Goal 加 Goal extension写入线程目标,管理状态、预算和续跑是,持久化到线程目标存储

在 codex-rs/tui/src/slash_command.rs 中,命令枚举明确包含 Compact 和 Goal,没有 Simplify。枚举同时决定命令描述、是否接受行内参数,以及任务运行时是否可用。/compact 不接受行内参数,/goal 接受;这已经说明它们不是一套“把字符串转给模型”的别名,而是有各自协议的前端入口。

pub enum SlashCommand {
// ...
Compact,
// ...
Goal,
// ...
}

这段枚举和 supports_inline_args 能力判断见 Codex 0.155.1 的 slash command 定义。实际匹配列表包含 Goal,不包含 Compact。因此,本文会把 simplify 写成 Skill,而不会为了满足名称对称性,虚构一个不存在的 SlashCommand::Simplify。

二、Slash 输入怎样进入执行链#

TUI 的 slash 输入先由 SlashInput::validate_submission 解析。内置命令会走 SlashCommand 枚举,另外还有少量由配置提供的 service-tier command;未知名字会在提交前返回“未知命令”。对支持行内参数的命令,解析器再把首个 token 后面的文本作为参数传入 dispatch 层。

flowchart LR A["用户输入 /compact 或 /goal"] --> B["SlashInput::validate_submission"] B --> C["SlashCommand 枚举"] C --> D["slash_dispatch.rs"] D --> E["AppCommand / AppEvent"] E --> F["core 或 extension"] F --> G["模型上下文、线程状态或工作区"]

slash_dispatch.rs 是三条路径的分叉点。它先检查当前是否有直接输入、代码审查或任务运行限制,再进入 match command。这层的职责不是实现压缩和目标管理,而是把用户意图翻译成应用事件,同时维护输入队列、状态指示器和历史显示。

这也解释了一个常见误判:命令出现在输入框里,不代表所有逻辑都在 TUI 中完成。TUI 只保留交互所需的状态,真正的压缩、目标存储和模型调用在更下层完成。

三、/compact:从按键到上下文替换#

/compact 的关键结论是:它不是“删除几条旧消息”,而是启动一个独立的压缩 turn,让模型生成新的历史表示,再把这份表示安装回会话。

3.1 dispatch 层只负责启动压缩#

在 slash_dispatch.rs 中,Compact 分支做了几件很具体的事情:清空当前 token 统计,设置“正在压缩”的状态,标记用户 turn 已排队,最后发送 AppEvent::Compact。它不会在 TUI 里拼接摘要,也不会直接读取 transcript。

SlashCommand::Compact => {
if self.blocks_direct_input {
self.add_error_message(PARENT_OWNED_INPUT_MESSAGE.to_string());
return;
}
self.clear_token_usage();
if !self.bottom_pane.is_task_running() {
self.bottom_pane.set_task_running(/*running*/ true);
}
self.bottom_pane.ensure_status_indicator();
self.set_status(
compaction::COMPACTION_HEADER.to_string(),
Some(compaction::COMPACTION_DETAILS.to_string()),
StatusDetailsCapitalization::Preserve,
STATUS_DETAILS_DEFAULT_MAX_LINES,
);
self.input_queue.user_turn_pending_start = true;
self.app_event_tx.compact();
}

上面是 Compact 的 TUI dispatch 分支 的真实节选。应用层收到事件后,经由 thread_routing.rs 调用 thread_compact_start(thread_id),再由 app-server 的 ThreadCompactStart 处理器 提交 Op::Compact。这样设计的好处是手动压缩和远程客户端发起的压缩可以复用同一个线程入口。

3.2 核心任务有三种实现选择#

core/src/tasks/compact.rs 把压缩建模成 SessionTask,任务类型是 TaskKind::Compact。run 方法按能力和配置选择实现:

  1. 启用 TokenBudget 路径时,使用 token budget 专用实现。
  2. provider 支持 remote compaction V2 时,把当前 prompt 和历史交给远端压缩接口。
  3. 其他情况使用本地压缩:调用普通模型,携带 SUMMARIZATION_PROMPT,再由本地会话安装结果。
let result = match ctx.provider.capabilities().remote_compaction {
RemoteCompactionSupport::V2 => {
crate::compact_remote_v2::run_remote_compact_task(session.clone(), ctx).await
}
RemoteCompactionSupport::Unsupported => {
let input = vec![UserInput::Text {
text: ctx.config.compact_prompt.as_deref()
.unwrap_or(crate::compact::SUMMARIZATION_PROMPT).to_string(),
text_elements: Vec::new(),
}];
crate::compact::run_compact_task(session.clone(), ctx, input).await
}
};

这是 CompactTask 选择逻辑 的结构节选。它省去了 token-budget 提前返回、埋点和错误收尾;实际函数先检查该 feature,再选择远程或本地路径。重要的不是分支名字,而是压缩并非固定由某一个模型或某一种 API 完成。

3.3 本地压缩的 prompt 是一个交接摘要契约#

本地路径把当前历史作为模型输入,并使用内置的 SUMMARIZATION_PROMPT。模板要求输出给“下一个继续任务的 LLM”,至少覆盖进度、关键决策、约束和偏好、剩余步骤,以及继续工作所需的资料。

You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM.

完整模板还要求摘要覆盖进度、关键决策、约束和偏好、剩余步骤及关键资料,见 compact/prompt.md。这里的“交接”很关键:摘要不是给用户看的总结,而是下一次模型调用的工作记忆。它应该保留可执行状态,不需要复述每一次工具调用。

3.4 replace_compacted_history 才是压缩的核心动作#

摘要生成后,core/src/compact.rs 会从最后一条 assistant 消息生成 summary 文本,并从历史中挑选预算内的用户消息。它把这些消息、摘要和可选的初始上下文装成新 history,再调用 Session::replace_compacted_history,记录窗口编号、压缩 response id 和模型 hash,最后重新计算 token 使用量。下面只保留关键调用,变量来自同一函数的前置步骤。

let summary_text = format!("{SUMMARY_PREFIX}\n{summary_suffix}");
let user_messages = collect_annotated_user_messages(history_items, identity);
let mut new_history = build_compacted_history(Vec::new(), &user_messages, &summary_text);
// 这里还会按 initial_context_injection 插入初始上下文。
sess.replace_compacted_history(
new_history,
reference_context_item,
world_state_baseline,
CompactedHistoryMetadata {
message: summary_text,
window_number,
window_ids,
compaction_response_id: Some(compaction_response_id),
compaction_model_hash: turn_context.model_info().comp_hash.clone(),
},
).await;
sess.recompute_token_usage(&turn_context).await;

这段逻辑见 本地 compaction 的历史替换流程,持久化 checkpoint 的细节见 Session::replace_compacted_history。因此,压缩有两个同时成立的事实:

  • transcript 或 rollout 中仍可以记录压缩边界和替换历史,压缩不是把会话文件物理清空。
  • 模型下一轮看到的是新历史,旧的工具输出和中间推理如果没有进入摘要,就不能再作为当前上下文中的可靠事实。

远程 V2 路径也遵守同一个安装契约。它从远端响应拿到 compaction_output,构造新的 history,调用 replace_compacted_history,写入 response id 和 compaction model hash,再重新计算 token。远程和本地的差别在“摘要在哪里生成”,不是在会话如何接受摘要。

3.5 失败处理、Hook 和自动压缩#

压缩本身也可能撞上上下文限制。compact.rs 在压缩请求再次触发 context exceeded 时,会移除较早的 history item 后重试;成功后再安装替换历史。代码还在压缩前后运行 compact hooks,并记录 local、remote、fallback 等 analytics 信息。

这解释了两个使用现象:

  • /compact 会短暂显示为一个正在运行的任务,而不是瞬间完成的字符串操作。
  • 手动压缩和自动压缩共享历史替换逻辑,但触发原因不同。自动路径使用 CompactionTrigger::Auto,手动路径使用 CompactionTrigger::Manual 和 CompactionReason::UserRequested;监控和统计可以据此区分它们。

/compact 的边界也很清楚:它不能恢复未被摘要保留的逐字细节,不能替代 Git 或外部状态存储,也不能解决固定系统提示词、工具 schema 本身占用过大的问题。若窗口主要被一次巨大的日志输出占满,先限制工具输出比反复压缩更有效。

四、simplify:Skill 注入,不是 slash handler#

4.1 为什么 /simplify 在源码里找不到#

当前版本在 slash command 功能启用、输入从行首开始时,parser 只接受 SlashCommand 枚举中的名字;rg 搜索整个 TUI 也找不到 SlashCommand::Simplify 或 /simplify 的注册。输入 /simplify 时,按这条解析链会被当作未知 slash command,而不是进入一个名为 simplify 的 Rust handler。

当前分析环境安装的 Skill 文件是 /root/.codex/skills/simplify/SKILL.md,它不是上游 Codex 仓库内置的 Rust handler。文件 frontmatter 把 simplify 声明为一个 Skill,描述是“审查最近修改的代码,关注复用、质量和效率,并应用高置信度、保持行为的简化”。在支持 Skill 的宿主环境里,常见触发方式是显式写 $simplify、直接提到 simplify,或由任务与 Skill 描述匹配后按 Skill 规则加载;具体输入别名由宿主版本决定。

它的入口实际上就是普通的 Markdown 元数据:

---
name: simplify
description: Review changed code for reuse, quality, and efficiency, then apply high-confidence behavior-preserving simplifications.
---

这个区别不是文字游戏。slash command 的行为由枚举和 dispatch 代码决定;Skill 的行为由目录发现、元数据预算、SKILL.md 读取以及模型遵循指令决定。

4.2 Skill 的源码链是“发现、读取、注入”#

Codex 的 Skill extension 大致有三层:

  1. catalog.rs 发现 Skill 元数据,生成名称、描述和路径。
  2. render.rs 按预算把可用 Skill 列表渲染进系统提示词,并对描述做截断或轮询分配。
  3. host_prompt.rs 对被选中的 Skill 读取完整 SKILL.md,生成 SkillInstructions 上下文片段,注入当前模型 prompt。

这里的预算不是无限的:默认 Skill 元数据预算是 8,000 个字符,配置为 token 预算时上限是 10,000 tokens;按上下文窗口计算时默认占 2%,目录中的单条描述最多 1,024 个字符,被选中的 agent-plugin 主 prompt 最多 8,000 字节。超预算时,系统会截短描述或移除部分描述,但不会因此把 simplify 变成一个独立执行器。

显式提及还要经过一条和 slash parser 完全不同的路径:input_submission.rs 从文本中提取 Skill 名称或路径,匹配当前启用的 Skill 后,向 UserInput 追加 Skill { name, path }。同时,Skill 目录 prompt 会把“用户点名 Skill,或任务匹配 Skill 描述时必须使用”写入模型可见指令;后者由模型依据描述做选择,不是 Rust 代码里的字符串路由。可对照 Skill mention 的输入转换 和 Skill 的触发规则模板。

host_prompt.rs 的关键代码很短:它遍历 selected skills,读取文本,必要时截断,构造 SkillInstructions,并记录注入结果。它没有调用 AST、编译器或独立的“简化引擎”。

let (contents, truncated) = if self.outcome().is_agent_plugin_skill(skill) {
truncate_main_prompt_contents(&contents)
} else {
(contents, false)
};
if truncated {
prompts.warnings.push(format!(
"Skill `{}` exceeded the main prompt context limit and was truncated.",
skill.name
));
}
prompts.fragments.push(Box::new(SkillInstructions {
name: skill.name.clone(),
path: skill.path_to_skills_md.to_string_lossy().into_owned(),
contents,
resource_access: None,
}));
prompts.injected.push(skill.clone());

见 host prompt 的 Skill 注入实现。这段节选省略了外层读取和错误分支。官方对 Skill 的定义也是“包含指令和资源的可复用工作流”,它与工具能力互补,而不是替代工具执行。可以参考 Skills 概念文档 对发现、读取和资源边界的说明。

4.3 simplify Skill 实际要求模型做什么#

当前 Skill 文件把流程拆成三个审查视角:复用、质量、效率。它要求先看 git status 和 diff,排除生成物、vendor、缓存和锁文件,再只修复“高置信度、保持行为”的问题;没有明确要求时不启动子 Agent,也不把一般性的代码润色当成简化任务。

因此,一次 simplify 调用的实际链路更接近下面这样:

flowchart LR A["Skill 名称或任务匹配"] --> B["发现 simplify 元数据"] B --> C["读取 SKILL.md"] C --> D["SkillInstructions 注入 prompt"] D --> E["模型检查 git diff"] E --> F["通过普通读写工具修改代码"] F --> G["运行相关验证并报告"]

这里没有一个保证“代码一定更简单”的系统事务。模型是否真正修改文件,取决于它对 diff、项目规则和验证结果的判断;Skill 只提供决策约束。它也不自动拥有跳过权限确认、跳过测试或批量修改生成文件的能力。

这正是把 simplify 错写成 slash command 后容易漏掉的边界:若需要确定性的重构,应使用编译器重写、静态分析器、格式化器或测试约束;Skill 适合把工程师的判断框架稳定地带入一次模型任务。

五、/goal:把“继续做完”变成线程状态机#

/goal 与前两者的差异最大。它不主要改变某一轮 prompt,也不只做一次压缩,而是为当前 thread 建立一个带状态、预算、计时和续跑规则的长期目标。

5.1 命令语法和事件分支#

goal_display.rs 给出的使用形式是:

Usage: /goal [<objective>|clear|edit|pause|resume]

裸 /goal 打开目标菜单;/goal <objective> 创建或更新目标;clear 清除目标;edit 修改目标;pause 和 resume 改变生命周期状态。dispatch 层会把这些动作分别转成 OpenThreadGoalMenu、SetThreadGoalDraft、ClearThreadGoal 和 SetThreadGoalStatus 等应用事件,具体分支见 slash dispatch 的 Goal 实现 和 行内参数处理。

对象太长时,TUI 不会把所有文本硬塞进数据库的一行。goal_files.rs 会把粘贴内容写入 $CODEX_HOME/attachments/<UUID>/;超过目标字符上限的 objective 会写成 goal-objective.md,数据库里的目标改为“请先读取这个文件”的引用。这样既保留完整目标,又避免每一轮都复制超长文本。

5.2 数据库存什么#

Goal extension 使用独立的 state 数据库。0.155.1 的迁移文件定义了一个以 thread_id 为主键的 thread_goals 表,以及一个记录续跑暂缓状态的 thread_goal_continuation_deferrals 表。

CREATE TABLE thread_goals (
thread_id TEXT PRIMARY KEY NOT NULL,
goal_id TEXT NOT NULL,
objective TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN (
'active', 'paused', 'blocked', 'usage_limited',
'budget_limited', 'complete'
)),
token_budget INTEGER,
tokens_used INTEGER NOT NULL DEFAULT 0,
time_used_seconds INTEGER NOT NULL DEFAULT 0,
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL
);

完整迁移见 thread goals 的 0001 migration。这里有一个重要的工程判断:目标是 thread 级持久化状态,不是全局“记住我想做什么”的模型记忆。换一个 thread,不会因为模型参数相同就自动继承这个 goal。

state/src/runtime/goals.rs 还负责几个状态不变量:替换目标时生成新的 goal_id 并清零 tokens/time;account_thread_goal_usage 会把负的 token/time 增量钳制为零,并在达到 token budget 时从 active 转为 budget_limited;更新时携带 expected goal id,避免旧的异步事件覆盖新目标。

5.3 设置目标的服务端路径#

TUI 事件最终进入 app-server 的 thread_goal_processor.rs。处理器会检查 feature flag,解析 thread 和状态,读取最大预算,调用 GoalService::set_thread_goal,写入 rollout 的 ThreadGoalUpdated 事件,再把变更发送给客户端并应用 runtime effects。

flowchart LR A["/goal objective"] --> B["GoalDraft / SetThreadGoal"] B --> C["thread_goal_processor"] C --> D["GoalService + SQLite"] C --> E["GoalRuntimeHandle"] D --> F["状态、预算、使用量"] E --> G["当前 turn 的 steering 或续跑"]

这种拆分把持久化和运行时分开:数据库回答“目标是什么、花了多少、现在是什么状态”,runtime 回答“当前线程是否已经空闲,是否应该立即再启动一轮”。

5.4 续跑不是定时器,而是一个有门槛的 idle 检查#

ext/goal/src/runtime.rs 的 continue_if_idle 是 /goal 最容易被低估的部分。它不会无条件创建一个后台循环,而是依次检查:工具是否仍可用、目标状态是否为 active、数据库中是否标记了 continuation deferral、线程是否存在并且当前确实 idle。条件满足后,runtime 构造一条内部 goal steering item,调用 thread.start_turn_if_idle;成功启动后才把这轮标记为 goal continuation。

下面是保留关键条件的伪代码,省略了 state DB、线程管理器和错误处理的完整限定名;真实调用见上面的源码链接。

let Some(goal) = state_dbs.thread_goals().get_thread_goal(thread_id).await? else {
return Ok(());
};
if goal.status != ThreadGoalStatus::Active || continuation_deferred(thread_id).await? {
return Ok(());
}
let item = continuation_steering_item(&protocol_goal_from_state(goal), update_plan_enabled);
match thread
.start_turn_if_idle(
TurnInputRequest::new(TurnInput::ResponseItem(item)).on_start(TurnStartOptions {
turn_trigger: Some("goal".to_string()),
..start_options
}),
)
.await
{
Ok(StartIfIdleSubmission::Started { turn_id }) => {
accounting_state.mark_goal_continuation(turn_id);
}
_ => {}
}

这段是 Goal runtime 的 idle continuation 的压缩节选,省略了状态锁、线程管理器和错误日志。它回答了一个实际问题:为什么模型发完一条看似完成的回复后,有时还会继续工作。只要目标仍为 active、线程回到 idle、没有续跑暂缓,系统就可能用内部 steering 再启动一轮。

5.5 steering prompt 如何限制模型的“完成”判断#

续跑不是把原始 objective 再贴一遍。steering.rs 使用 continuation.md 模板,把 XML 转义后的 objective、已用 token、预算和剩余预算填入内部上下文片段。模板明确要求模型:

  • 以当前目标作为持续任务,不要擅自缩小成功条件。
  • 根据工作区或外部系统中的证据继续推进,而不是凭“看起来完成”停止。
  • 没有进展时先重新验证,不要在同一阻塞条件上无限循环。
  • 只有逐项核对权威证据后,才把目标标为 complete。
  • 相同阻塞条件连续重复达到规定次数后,才能进入 blocked。

其中 objective 会经过 escape_xml_text,避免用户目标中的 <、> 和 & 破坏模板结构。这是模板结构保护的一部分,不能单独阻止 prompt 注入;真正的文件、网络和进程限制仍由工具权限和沙箱负责。

完整规则在 Goal continuation 模板,prompt 的渲染和 XML 转义见 steering.rs。

5.6 预算和终止状态如何产生#

Goal runtime 会在 turn 结束或异常时记录 token 和时间增量。状态更新使用 expected goal id,防止旧 turn 的异步完成事件污染新目标。典型状态转移如下:

stateDiagram-v2 [*] --> active: /goal objective active --> paused: /goal pause paused --> active: /goal resume blocked --> active: /goal resume usage_limited --> active: /goal resume active --> budget_limited: 达到 token budget active --> usage_limited: 外部使用量限制 active --> blocked: turn error / empty response active --> complete: 证据证明所有要求完成 active --> [*]: /goal clear paused --> [*]: /goal clear blocked --> [*]: /goal clear usage_limited --> [*]: /goal clear budget_limited --> [*]: /goal clear complete --> [*]: /goal clear

complete 和 blocked 不是用户界面上的装饰标签。它们会阻止后续自动续跑;budget_limited 和 usage_limited 则把资源边界显式传给下一次交互。官方 Cookbook 也把 Goal 描述为附着在线程上的持久目标,并强调基于证据的完成判断,见 Using Goals in Codex。

六、三者放在一起比较#

把实现链压缩成一张表,可以看清它们控制的是不同对象:

机制控制对象模型是否必须参与持久化位置主要失败方式
/compact当前上下文的历史表示是,摘要通常由模型生成session history / rollout checkpoint摘要丢失细节、压缩请求失败、仍受固定 prompt 开销影响
simplify Skill一次任务的行为约束是,遵循指令并选择工具SKILL.md 和工作区修改指令未触发、模型误判重构价值、验证不足
/goalthread 的长期目标和生命周期是,模型根据 steering 推进goals SQLite、rollout、runtime 状态错误或空响应进入 blocked、预算耗尽、无进展被延迟

从控制面看,/compact 改变的是“模型看见怎样的过去”,simplify 改变的是“模型怎样处理当前 diff”,/goal 改变的是“模型什么时候还要继续下一轮”。三者可以串联使用,但不能互相替代:压缩不能创建目标,Skill 不能提供后台续跑,Goal 也不能修复一个已经丢失的上下文事实。

七、实际使用时的判断顺序#

遇到长任务时,可以按下面的顺序决定是否使用它们:

  1. 先确认目标是否跨多个 turn 且需要“做到证据满足”为止。是的话,用 /goal;目标应写成可验证的结果,而不是“继续看看”。
  2. 如果只是上下文接近上限,先控制大输出,再用 /compact;需要保留的决策、未完成步骤和验证结果要明确写入压缩 prompt 或当前工作记录。
  3. 如果任务是对已有 diff 做保守清理,使用 simplify Skill;把它视为审查框架,不要把它当作确定性重构器。
  4. 对不可违反的边界使用权限、沙箱、Hook、测试和 CI。三种机制都包含模型指令或模型生成摘要,不能单独承担安全策略。

还有一个版本边界需要保留:本文代码链接对应 rust-v0.155.1。命令枚举、feature flag、Skill 触发语法、Goal 状态名称和远程压缩协议都可能在后续版本调整。阅读源码时,先对齐发行版本,再沿着本文的五层路径重新确认,而不是只复制某个函数名。

参考源码与文档#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

赞助
Codex 内置子命令深度解析:/compact、simplify Skill 与 /goal
https://blog.souloss.cn/posts/_draft/codex-built-in-commands-compact-simplify-goal/
作者
Tsukimi
发布于
2026-09-25
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时