怎样让 Codex 真正会用你的工具
最近读到 Thariq Shihipar 写的一篇帖子:The new rules of context engineering for Claude 5 models。其中有一句很值得做工具的人认真想一想:
过去给模型大量工具调用示例,现在更应该设计好接口。
原帖讨论的是 Claude Code。这个结论能不能直接搬到 Codex,我不想只靠感觉判断,于是做了一个小实验。
我实现了一个真正对编程 Agent 有用的 MCP 工具:代码变更影响分析器。它读取 Git diff、CODEOWNERS 和仓库结构,告诉 Codex:
- 哪些模块受到影响;
- 应该找哪些人 review;
- 哪些测试和生成脚本必须运行;
- 是否涉及数据库迁移、认证边界、API 契约和部署流程;
- 输出是否因为数量上限被截断。
同一套底层逻辑,我做了三个接口版本,再让 Codex CLI 完成 6 类任务,每类重复两次,共运行 36 次。
结果很直接:工具后端一行算法都没改,只调整接口,最终答案通过率就从 7/12 变成 12/12。更早的无安全注解版本,12 次调用则全部被 Codex 默认策略拦截。
这篇文章记录完整过程。重点不是证明某个 Schema 可以包治百病,而是回答一个更实用的问题:
一个工具到底应该长成什么样,Codex 才能稳定发现它、填对参数、拿到够用而不过量的结果,并且知道这次调用是否安全?
接上 MCP,不等于 Codex 会用
MCP 把外部能力接进 Codex。按照 Codex 的 MCP 文档,Server 可以提供工具、资源和提示词;工具通过名称、描述和 JSON Schema 暴露参数。
从协议上看,下面这个工具完全合法:
{
"name": "analyze",
"description": "Analyze repository data and return useful information.",
"inputSchema": {
"type": "object",
"properties": {
"data": { "type": "string" },
"type": { "type": "string" },
"options": { "type": "object" }
},
"required": ["data"]
}
}
它的问题也很明显。
analyze 分析什么?data 是文本、文件路径、Git revision 还是 URL?type 有哪些合法值?options 里能放什么?它会不会修改仓库?返回的是结论、原始数据,还是一大段日志?
人类开发者会去翻 README、看示例、读实现。Codex 做一次工具选择时,主要依赖的就是眼前的工具定义和当前任务。Schema 留下的空白,模型只能自己补。
这不是模型偶尔粗心,而是接口要求调用方猜协议。
工具定义本身就是模型看到的界面
传统 API 的主要调用者是程序。编译器、类型检查器和 IDE 能在开发阶段发现很多问题。
Agent 工具的调用者是模型。它会先根据名称和描述判断要不要使用,再把自然语言目标翻译成参数,最后理解返回结果并决定下一步。
一条工具链至少有四层契约:
- 发现契约:工具叫什么,何时应该用,何时不该用;
- 输入契约:用户意图怎样落到有限、合法的参数;
- 执行契约:是否只读、是否有副作用、能否重复调用;
- 输出契约:返回什么、证据在哪里、是否截断、失败后怎么办。
任何一层含糊,Codex 都可能在错误的地方“发挥智能”。

示例工具:代码变更影响分析器
测试仓库里有两个提交。第二个提交改了 5 个文件:
.github/workflows/deploy.yml
backend/auth/service.py
backend/db/migrations/20260727_add_sessions.py
backend/openapi/schema.json
frontend/src/login.ts
仓库还包含一份 CODEOWNERS:
/backend/auth/ @identity-team
/backend/db/ @data-platform
/backend/openapi/ @api-platform
/frontend/ @web-team
/.github/workflows/ @release-engineering
工具做的是确定性分析,不调用模型:
def assess_change_impact(base_ref, head_ref, component, focus):
changed_files = git_diff(base_ref, head_ref)
scoped_files = filter_by_component(changed_files, component)
return {
"changed_files": scoped_files,
"components": detect_components(scoped_files),
"owners": match_codeowners(scoped_files),
"required_checks": infer_checks(scoped_files),
"risks": detect_release_risks(scoped_files),
"truncated": False,
}
它能识别几类具体规则:
| 变更 | 风险 | 必要检查 |
|---|---|---|
backend/auth/** | AUTH_BOUNDARY | 认证测试与后端测试 |
backend/db/migrations/** | DATABASE_MIGRATION | 升级与回滚迁移 |
backend/openapi/** | API_CONTRACT | 重新导出并检查文档 diff |
frontend/** | 前端构建与登录链路 | 单测、构建、端到端登录测试 |
.github/workflows/** | RELEASE_WORKFLOW | actionlint 与部署验证 |
这个例子有意选得比天气查询复杂。它既有文件、枚举和数量限制,又有只读安全语义;有些任务需要完整结果,有些只需要某个组件的责任人或检查命令。只有这种任务,才能看出接口设计是否真的帮到了 Codex。
第一版:把复杂度塞进 options
第一版就是前面的 analyze(data, type, options)。
为了先排除安全注解的影响,我给它补上了只读、幂等和封闭世界注解,但没有改变名称与输入 Schema。Codex 随后生成了这些参数:
{
"data": "HEAD~1...HEAD",
"type": "git_range",
"options": {
"scope": "authentication",
"focus": "release_risks",
"include_only_risks": true,
"include_changed_files": true,
"include_risk_codes": true,
"run_checks": false
}
}
另一轮要求“最多返回两个文件”时,它写的是:
{
"data": "HEAD~1...HEAD",
"type": "git",
"options": {
"maxFiles": 2
}
}
第二次又变成:
{
"data": "HEAD~1...HEAD",
"type": "git_range",
"options": {
"max_files": 2
}
}
从自然语言角度看,这些参数都很合理。问题是工具并不认识 scope、include_only_risks、maxFiles 或 max_files。它只认识实现里约定的 focus、paths 和 limit。
模型认真表达了意图,接口却没有给它合法的表达方式。
最终答案正确,工具调用也可能已经错了
更有迷惑性的是,Codex 有时仍能给出正确答案。
工具忽略了不认识的字段,扫描完整 diff,返回 5 个文件、所有 owner、10 条检查命令和 5 项风险。Codex 再从这堆结果里筛出数据库迁移的两条命令:
uv run alembic upgrade head
uv run alembic downgrade -1
只看最终文本,这一题通过了。
但工具调用实际有三个问题:
- 用户只问数据库,工具却分析了完整仓库;
- 返回结果包含大量无关内容;
- 这次是 Codex 筛对了,下次可能把无关风险一起带进答案。
所以评测工具不能只看最终回答。还要看原始参数、工具结果和完整调用轨迹。
第二版:参数应该表达业务意图
我先把第一版改成下面这样:
{
"name": "assess_change_impact",
"description": "Read a Git diff and report affected components, CODEOWNERS, required checks, and release risks.",
"inputSchema": {
"type": "object",
"properties": {
"base_ref": { "type": "string", "default": "HEAD~1" },
"head_ref": { "type": "string", "default": "HEAD" },
"focus": {
"type": "string",
"enum": ["all", "tests", "owners", "release_risk"]
},
"path_filters": {
"type": "array",
"items": { "type": "string" }
},
"max_changed_files": {
"type": "integer",
"minimum": 1,
"maximum": 200
}
},
"additionalProperties": false
}
}
名称终于说明了动作,focus 把输出目的限制成四种,数量上限也有明确边界。
认证、迁移和前端任务都明显改善了。不过 API 契约任务仍然失败:Codex 知道用户要 tests,却不知道 API 契约在这个仓库里对应 backend/openapi/**,于是没有填写 path_filters,工具还是返回了完整 diff 的检查命令。
这次不是描述不够长,而是参数抽象层级不对。
path_filters 是存储结构,api_contract 才是用户和 Codex 想表达的业务意图。
最终版:让工具自己知道仓库知识
最终版增加了一个语义参数:
{
"component": {
"type": "string",
"enum": [
"all",
"authentication",
"database_migration",
"api_contract",
"frontend",
"ci_cd"
],
"description": "Stable semantic scope. The tool maps it to repository paths.",
"default": "all"
}
}
目录映射留在工具内部:
COMPONENT_FILTERS = {
"authentication": ["backend/auth/**"],
"database_migration": ["backend/db/migrations/**"],
"api_contract": ["backend/openapi/**"],
"frontend": ["frontend/**"],
"ci_cd": [".github/workflows/**"],
}
path_filters 没有删除,但降级成高级参数:只有用户要求某个特殊目录时才需要填写。常见任务走稳定的组件枚举。

现在,API 契约任务的真实调用变成:
{
"base_ref": "HEAD~1",
"head_ref": "HEAD",
"component": "api_contract",
"focus": "tests"
}
工具只返回:
{
"changed_files": [
"backend/openapi/schema.json"
],
"required_checks": [
"git diff --exit-code docs/openapi",
"uv run python scripts/export_openapi.py"
],
"summary": "1 changed file affects the API contract."
}
Codex 不需要知道 OpenAPI 文件放在哪,也不需要从十条命令里猜哪两条与问题有关。
这里有一条很实用的设计原则:
参数应该接近用户意图,目录、表名、供应商字段和内部 ID 的映射尽量留在工具内部。
当然,前提是这个语义稳定。如果组件集合每天变化,硬编码枚举会变成新的维护负担。可以由工具提供一个只读的组件发现能力,或者从仓库配置动态生成枚举,但不要把不稳定的内部结构直接推给模型。
36 次真实调用的结果
测试环境如下:
- Codex CLI
0.145.0; - Python
3.14.4; - Git
2.53.0; - STDIO MCP Server;
- 只读沙箱;
- 每次任务使用独立的非交互 Codex 会话;
- 6 类任务,每类 2 次,每个版本 12 次。
任务包括:
- 只查认证部分的发布风险;
- 只列数据库迁移的验证命令;
- 只找前端登录文件的 owner;
- 只列 API 契约的必要检查;
- 分析完整 diff;
- 最多返回两个文件并说明是否截断。
三个版本
| 版本 | 接口特征 |
|---|---|
| A:旧式接口 | analyze(data, type, options),没有工具注解 |
| B:含糊接口 | 同样的自由参数,补齐只读等注解 |
| C:语义接口 | 明确名称、组件与关注点枚举、数量边界、结构化输出 |
结果
| 指标 | A:旧式接口 | B:含糊接口 | C:语义接口 |
|---|---|---|---|
| 运行次数 | 12 | 12 | 12 |
| 工具调用错误 | 12 | 0 | 0 |
| 最终答案通过 | 0 | 7 | 12 |
| 参数准确表达意图 | 2 | 3 | 12 |
| 工具结果范围正确 | 0 | 2 | 12 |
| 平均返回变更文件数 | 0 | 5.00 | 1.83 |
| 平均工具结果字符数 | 2 | 1766 | 1455 |
| 平均完成时间 | 17.43 秒 | 31.00 秒 | 16.45 秒 |

有几个数字需要单独解释。
A 版本为什么全部报错
它没有声明 readOnlyHint。MCP 规范里的工具注解只是提示,而且客户端不能盲目信任不可信 Server;但在受信任的本地 Server 上,Codex 会用这些信息参与副作用和审批判断。
在这组非交互、只读评测里,A 版本被当成可能有副作用的工具,12 次调用都进入了取消状态。这不是工具业务逻辑失败,而是工具没有把自己的执行语义告诉 Codex。
B 版本为什么耗时反而更长
B 版本每次都返回全部 5 个文件。Codex 需要从长结果中再次筛选,有些任务还把无关项目带进最终答案。
平均 31 秒不能被解读成严格性能基准。模型服务时延存在波动,样本也只有 12 次。但它至少说明:返回更多内容不会自动让 Codex 更快、更可靠。
C 版本的字符数只减少了约 18%
完整 diff 任务本来就需要全部内容,而且结构化 JSON 带有字段名,因此字符数没有像文件数一样大幅下降。
真正明显的变化是平均文件数从 5 降到 1.83,目标范围正确率从 2/12 提升到 12/12。对于更大的真实仓库,这种范围控制通常比省几百个字符更重要。
这不是通用排行榜
这组实验的限制也很明确:
- 只有一个小型仓库和一种工具;
- 每个任务只重复两次;
- 提示词明确要求使用 MCP,没有评估“上百个工具中能否主动发现它”;
- 评测的是接口设计,不是变更影响算法的准确率;
- 没有读取或评判模型的隐藏推理,只检查可观察的调用、结果和答案;
- 平均耗时受模型服务状态影响,不适合作为不同 Codex 版本之间的性能结论。
它足以说明接口改造在这个场景里产生了稳定差异,但不应该被写成“所有工具都能提升多少”的结论。
工具名首先解决路由问题
analyze、query、execute、process 这类名字很适合内部函数,不适合放进一组 Agent 工具。
工具名最好同时包含资源和动作:
assess_change_impact
search_incidents
get_deployment_status
create_release_candidate
cancel_pending_job
如果多个 MCP Server 里都有 search,应加服务或资源命名空间:
github_search_issues
docs_search_pages
metrics_search_series
名称不是越长越好。关键是 Codex 只看名字时,也能大致判断它与当前任务有没有关系。
工具描述的第一句则回答“做什么”和“什么时候用”:
Read a Git diff and report affected components, CODEOWNERS,
required checks, and release risks. Use before planning
verification, assigning reviewers, or merging a change.
接着说明重要边界:
This tool is read-only. It does not run tests, edit files,
or contact GitHub.
不要在每个工具描述里重复整套团队流程。跨工具都适用的限制可以放在 MCP Server 的 instructions 中。Codex 文档建议 Server Instructions 的前 512 个字符能够自解释,适合放限流、共同约束和跨工具流程。
Schema 是给 Codex 使用的一门小语言
JSON Schema 不只是参数校验器,它定义了 Codex 可以怎样表达意图。
用枚举表达有限选择
下面的字段让 Codex 自己发明值:
{
"mode": { "type": "string" }
}
实际调用里很快会出现:
release_risk
release_risks
release-risk
deployment_risk
risks_only
如果系统只有四种合法行为,直接写枚举:
{
"focus": {
"type": "string",
"enum": ["all", "tests", "owners", "release_risk"]
}
}
枚举值应该描述语义,而不是把数据库里的 type=3 暴露出来。
不要让自由对象承担协议
options: object 看起来扩展性很好,实际上等于没有契约。
真正可选的能力应成为明确字段。确实需要键值映射时,也要限制键和值:
{
"labels": {
"type": "object",
"additionalProperties": {
"type": "string",
"maxLength": 80
},
"maxProperties": 20
}
}
默认值要符合最安全的常见路径
只读查询可以有方便的默认值:
{
"base_ref": { "type": "string", "default": "HEAD~1" },
"head_ref": { "type": "string", "default": "HEAD" },
"component": { "enum": ["all", "..."], "default": "all" }
}
写操作不要用危险默认值。删除工具不应默认“删除全部”,发布工具也不应在省略环境时默认生产。
限制范围要成为一等参数
分页、时间范围、最大结果数、字段投影和截断标志,应该在接口里明确存在:
{
"max_changed_files": {
"type": "integer",
"minimum": 1,
"maximum": 200,
"default": 100
}
}
否则 Codex 只能在拿到过量结果以后自己截断。此时费用和上下文已经消耗,工具端的查询压力也已经发生。
禁止悄悄接收拼错的字段
{
"type": "object",
"additionalProperties": false
}
如果第一版接口设置了这一项,maxFiles、max_files 和 scope 会立即变成可见错误,而不是被工具静默忽略。
严格失败并不可怕。静默忽略参数、返回一个看似成功的宽泛结果,才最难排查。
输出也需要 Schema
MCP Tools 规范支持 outputSchema 和 structuredContent。它们能让客户端和模型知道返回字段的类型,也能在工具边界做校验。
这个示例的核心输出是:
{
"changed_files": ["backend/auth/service.py"],
"components": ["authentication", "backend"],
"owners": {
"backend/auth/service.py": ["@identity-team"]
},
"required_checks": [
"uv run pytest backend/tests/test_auth.py"
],
"risks": [
{
"code": "AUTH_BOUNDARY",
"severity": "high",
"evidence": "backend/auth/service.py",
"reason": "Authentication changes can alter authorization boundaries."
}
],
"truncated": false,
"summary": "1 changed file; 1 high release risk."
}
这比一段混合 owner、命令和风险的长文本更适合 Codex继续处理。
每个结论带证据
不要只返回:
{ "risk": "high" }
至少要说明:
- 风险代码;
- 严重程度;
- 触发它的文件或数据;
- 判断原因;
- 下一步可以执行的检查。
模型不应该凭一枚 high 标签重新编造解释。
明确区分空结果、部分结果和失败
这三种情况完全不同:
{
"status": "complete",
"items": [],
"truncated": false
}
{
"status": "partial",
"items": ["..."],
"truncated": true,
"next_cursor": "..."
}
{
"status": "error",
"error": {
"code": "INVALID_GIT_REVISION",
"retryable": false,
"hint": "Use a revision visible in this repository."
}
}
如果失败时仍返回 HTTP 200 和一句藏在正文里的错误,Codex 很容易把它当成空结果继续推理。
返回高信号,不返回后台仓库
工具结果要帮助下一步决策,不是把底层所有字段搬进上下文。
常见的降噪方法包括:
- 默认只返回必要字段;
- 让调用方选择
focus或字段投影; - 对日志保留证据行与前后文,而不是完整文件;
- 对列表设置上限和游标;
- 原始大结果保存成资源链接,正文只返回摘要;
- 稳定排序,避免同一结果每次顺序不同。
安全注解不是装饰
MCP 工具可以提供:
{
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": true,
"openWorldHint": false
}
这些注解分别说明:
- 是否只读;
- 是否可能产生破坏性更新;
- 同样参数重复调用是否会产生额外效果;
- 是否会与开放的外部世界交互。
规范明确说明它们只是 hint,客户端不能把来自不可信 Server 的声明当作安全事实。但对于受控的 MCP Server,正确注解能帮助 Codex 选择审批策略。
本次实验里,无注解版本在默认非交互环境下 12 次全部被取消,就是一个很具体的例子。
反过来也不能为了少一次审批,把写工具谎报成只读。工具真正的权限、租户隔离、参数校验和审批仍要由执行端强制保证。
工具、Skill 和 AGENTS.md 各管什么
很多团队发现工具不好用,就继续往系统提示词或 AGENTS.md 里加说明:
使用 analyze 时,type 必须写 git_range。
数据库任务在 options.focus 写 database migration。
限制数量时写 limit,不要写 maxFiles。
这相当于用文档修补一个本可以由类型系统表达的问题。
在 Codex 里,更合理的分工是:
| 载体 | 适合放什么 |
|---|---|
| Tool Schema | 单次能力、参数、输出和副作用 |
| MCP Server Instructions | 同一 Server 的共同约束、限流和跨工具规则 |
| Skill | 多步工作流、团队经验、何时组合哪些工具 |
AGENTS.md | 仓库约定、命令、验证方式和容易踩的坑 |
| 用户 Prompt | 这一次任务的目标、范围和交付要求 |
Codex 对 Skill 使用渐进式加载:先看名称和描述,选择后再加载完整 SKILL.md,需要时才读取引用和脚本。复杂工作流适合放 Skill,原子能力适合放 Tool。
例如:
assess_change_impact是 Tool;- “发布 PR 前先分析影响,按风险补测试,生成 reviewer 清单”是 Skill;
- “本仓库 OpenAPI 文件必须提交生成结果”是
AGENTS.md的仓库约定。
把三者揉成一段巨长工具描述,会让工具难以复用,也让每次调用都背上无关上下文。
示例到底要不要写
原帖提出“从给示例转向设计接口”,不是说示例从此没有价值。
示例最适合放在三个地方:
- 文档里,帮助人理解;
- 测试里,证明输入输出;
- 评测集里,检查 Codex 是否会用。
不应该依赖十几个 few-shot 示例,去解释一个本来可以由枚举、格式和字段描述表达的协议。示例可能让模型过拟合到某个路径,也会长期占用上下文。
本次实验的 api_contract 迭代正说明了这一点。第一版 path_filters 描述里给过认证目录示例,认证任务做对了,API 契约仍然没有映射成功。真正解决问题的不是再加五个目录示例,而是增加 component 这个语义字段。
怎样给自己的工具做评测
Anthropic 在 Writing effective tools for AI agents 中建议从真实任务构造工具评测,记录工具选择、参数、调用次数、错误和结果质量。虽然文章以 Claude 为主,这套实验方法同样适合 Codex。
先保存可观察轨迹
Codex CLI 的 --json 会输出 JSONL 事件,可以看到:
- 调用了哪个 MCP Server 和工具;
- 参数是什么;
- 工具结果是什么;
- 调用是否失败;
- 最终回答是什么;
- 一次任务用了多长时间。
本次评测命令的核心形式是:
codex exec \
--ephemeral \
--json \
--sandbox read-only \
-c 'mcp_servers.impact.command="python3"' \
-c 'mcp_servers.impact.args=["impact_server.py", "--variant", "good"]' \
"Use the change-impact capability to report only API contract checks."
输出事件中可以直接看到:
{
"type": "mcp_tool_call",
"server": "impact",
"tool": "assess_change_impact",
"arguments": {
"base_ref": "HEAD~1",
"head_ref": "HEAD",
"component": "api_contract",
"focus": "tests"
},
"status": "completed"
}
至少评四类指标
| 指标 | 要回答的问题 |
|---|---|
| 最终任务通过率 | 用户目标是否完成 |
| 参数意图准确率 | 参数是否真正表达了请求 |
| 结果范围准确率 | 工具是否只返回需要的数据 |
| 工具错误率 | Schema、审批和执行是否失败 |
还可以记录调用次数、结果大小、任务时延和重试次数。
评测集要包含相邻但不同的任务
如果只测“分析完整 diff”,含糊接口也可能表现很好,因为它本来就返回所有内容。
真正能拉开差异的是:
- 只要风险,不要测试;
- 只看一个组件;
- 只找 owner;
- 限制最多两条;
- 输入 revision 非法;
- 没有匹配文件;
- 结果超过上限;
- 工具明确返回不可重试错误。
这些任务在检查接口边界,不是在检查模型会不会复述结果。
一次只改一个变量
先补安全注解,再改名称和 Schema,再改输出。每次重跑同一组任务。
如果同时换模型、改 Prompt、重写工具逻辑和调整测试数据,最后即使分数提高,也不知道是谁起了作用。
一份可以直接使用的检查表
发现
- 工具名是否同时包含资源和动作;
- 描述第一句是否说明做什么、何时用;
- 是否写清“不会做什么”;
- 相似工具是否有清楚边界;
- 工具数量是否可以用
enabled_tools做最小化配置。
输入
- 参数名是否无歧义;
- 有限选择是否使用枚举;
- 日期、ID、路径和单位是否注明格式;
- 用户意图是否被迫翻译成内部实现细节;
- 是否有分页、范围、字段投影和数量上限;
- 是否设置
additionalProperties: false; - 默认值是否安全。
执行
readOnlyHint、destructiveHint、idempotentHint、openWorldHint是否真实;- 写操作是否有确定性权限检查;
- 重试是否会重复产生副作用;
- 长任务是否有超时、进度和取消;
- Secret 是否留在执行层。
输出
- 是否提供
outputSchema和structuredContent; - 结论是否带证据;
- 空结果、部分结果和错误是否可区分;
- 是否返回
truncated与继续读取方式; - 错误是否包含稳定代码、
retryable和修复提示; - 是否把大量低信号原始数据塞回上下文。
评测
- 是否保存原始工具调用和结果;
- 是否同时看最终答案与参数;
- 是否有相邻能力、边界和错误任务;
- 是否至少重复运行;
- 是否每次只改变一个主要变量;
- 线上失败是否会进入回归集。
最后
一个适合 Codex 的工具,不是把现有 REST API 原样包成 MCP 就结束了。
REST API 可能为了兼容多年调用方,保留宽泛的 options、内部状态码和复杂对象;Codex 需要的是另一层面向意图的接口:
- 名称帮助它找到能力;
- 描述告诉它何时使用;
- Schema 限制合法表达;
- 语义参数隐藏内部结构;
- 注解说明副作用;
- 结构化结果提供证据和边界;
- 错误告诉它能不能重试;
- Skill 再把多个工具组织成稳定流程。
模型越强,越不应该让它把能力浪费在猜字段、猜目录和过滤垃圾结果上。
最好的工具接口并不是给 Codex 更多“自由发挥”的空间,而是把真正需要判断的部分留给模型,把协议、权限、范围和错误处理做成清楚的工程契约。
微信公众号
欢迎关注「字与码」
如果这篇文章对你有用,也欢迎在微信里继续关注后续更新。
X / Twitter
关注 @ax2_zicode
更即时的技术观察、新文章提醒和一些短想法会发在 X 上。