← 返回全部章节
02

工具系统与仓库级上下文

工具契约、副作用、文件与 Shell,以及如何在真实仓库里选择高价值上下文。

11 分钟 02-tools-context.md

工具 API 的设计原则#

模型看到的工具不是普通内部 API。它的调用方具有概率性,因此接口应:

  • 语义单一: 一个工具做一类明确动作,避免几十个互斥参数;
  • 名字可区分: search_textfind_symbolqueryexecute 更易选对;
  • Schema 严格: 枚举、必填项、范围、路径类型尽量明确;
  • 结果紧凑: 先给关键事实,再给分页/句柄,避免淹没上下文;
  • 错误可行动: 告诉模型为何失败、能否重试、下一步选项;
  • 副作用可见: 描述中标注只读、写入、网络、持久、可逆;
  • 可观测: 每次调用有稳定 ID、时间、状态、资源消耗和产物引用;
  • 可取消、可限额: 超时、输出上限、进程树终止、并发限制;
  • 可演进: Schema 有版本,旧 session replay 时仍能解释历史调用。

一个结构化结果:

{
  "status": "failed",
  "error": {
    "kind": "stale_file",
    "retryable": true,
    "message": "文件在读取后被修改",
    "expected_sha256": "…",
    "actual_sha256": "…",
    "suggested_action": "重新读取目标片段后生成新 patch"
  },
  "metrics": {
    "duration_ms": 13,
    "output_bytes": 0
  }
}

为什么不只提供一个万能 Shell?#

Shell 表达力最强,但可发现性、结果结构、安全策略和跨平台性都差。读取、搜索、编辑、测试等高频动作适合专用工具:参数更受约束、结果更紧凑,也便于做权限与评测。Shell 仍作为逃生舱处理长尾任务。合理设计是“常用能力窄接口 + Shell 兜底”,不是彻底禁用 Shell。

工具应该粗粒度还是细粒度?#

过细会增加轮次、延迟和选择错误;过粗会隐藏中间状态、难以恢复和授权。我按“一个可独立理解、可授权、可重试的原子意图”切分。只读批量查询可以粗一些;有副作用的动作要细到能单独审计和批准。最终通过 trace 看工具误选率、平均调用数和任务成功率,而不是凭审美决定。

工具调用生命周期#

sequenceDiagram participant L as LLM participant R as Runtime participant P as Policy participant T as Tool participant W as Workspace L->>R: tool_call(id, name, args) R->>R: schema validation + normalize R->>P: authorize(effect, scope, args) alt 需要批准 P-->>R: pending approval R-->>L: 暂不产生虚假成功结果 else 允许 R->>T: execute(call_id, deadline, cancel_token) T->>W: perform action W-->>T: raw output / error T->>T: truncate + redact + classify T-->>R: structured result R->>R: persist result before next model call R-->>L: observation end

关键不变量:

  1. tool_call_id 在 session 内唯一;
  2. 每个已记录的 call 最终有且只有一个 terminal result;
  3. side effect 与事件落盘的次序必须明确;
  4. replay 不应重复执行历史副作用;
  5. 取消也要生成 interrupted 结果,不能留下悬空调用;
  6. 工具输出进入模型前要做大小限制、敏感信息处理和来源标注。

并行工具调用#

适合并行:

  • 多个互不依赖的只读搜索;
  • 独立文件读取;
  • 无共享环境的检查任务;
  • 明确隔离工作区的 Subagent。

不应直接并行:

  • 多个可能编辑同一文件的动作;
  • 一个命令依赖前一个命令生成的文件;
  • Git index / branch 等全局可变状态;
  • 会争抢端口、数据库或构建缓存的任务。

可以为工具声明 effect:

READ(path-set)
WRITE(path-set)
PROCESS(spawn)
NETWORK(domain-set)
GIT_INDEX
WORKSPACE_GLOBAL

调度器依据 effect 做冲突检测。模型提出的并行只是建议,Runtime 才是最终裁决者。

工具执行成功但结果事件还没落盘,进程崩溃了,如何恢复?#

这是典型的外部副作用与本地日志无法原子提交问题。

  • 只读工具可安全重放;
  • 幂等写工具使用 idempotency_key 和预期版本;
  • 文件编辑可检查目标内容 hash 或 patch 是否已应用;
  • 外部 API 若支持幂等键则透传;
  • 非幂等且无法确认的动作标记为 unknown_outcome,恢复时要求用户确认,不能盲目重放;
  • event log 中记录 intent、开始、结果和 side-effect fingerprint。

文件读取、搜索与编辑#

文件读取#

  • 默认带行号并限制行数/字节数;
  • 大文件支持 range、head、tail、按符号读取;
  • 二进制、压缩包、生成文件要识别;
  • 返回编码、换行符、是否截断、内容 hash;
  • 防止符号链接越界和路径穿越。

搜索#

  • 文本搜索优先使用仓库原生快速工具(如 ripgrep);
  • 支持 glob、语言、目录、最大结果数;
  • 结果分组去重,展示命中上下文;
  • 明确“0 结果”与“搜索失败/被截断”的区别;
  • 大结果返回句柄或分页,不把几万行塞回模型。

编辑策略对比#

策略 优点 主要风险 适用
整文件重写 简单,模型容易生成 token 大,误删并发修改,格式漂移 小文件或新文件
Search/Replace 直观、局部 锚点不唯一、空白敏感 唯一稳定片段
Unified Diff / Patch 可审阅、表达多处改动 模型 patch 可能不合法、上下文过期 通用代码编辑
AST/CST 编辑 结构安全、可重构 多语言成本高,保留格式困难 重命名、导入、结构改造
IDE/LSP WorkspaceEdit 与编辑器生态结合 依赖语言服务与客户端能力 IDE 场景

生产实现通常组合使用。关键保护:

  • 读时返回 base_hash,写时做 compare-and-swap;
  • patch 应用前检查目标路径与 hunk;
  • 写临时文件后原子替换,保留权限和换行;
  • 修改后立刻返回 diff 摘要;
  • 允许撤销,至少能恢复 Agent 自己的修改;
  • 用户已有改动不是 Agent 的“脏数据”,不得擅自覆盖。

用户在 Agent 运行中手动改了同一文件怎么办?#

不能以最后写入获胜。读文件时记录版本或 hash,写入时乐观并发控制;冲突后重新读取并尝试三方合并。若语义冲突无法自动解决,暂停让用户选择。Agent 的修改、用户原有未提交修改和基线版本应能区分,最好通过 patch provenance 或隔离 worktree 管理。

Shell 与长进程#

Shell 工具至少要处理:

  • 工作目录和环境变量的显式继承;
  • stdout / stderr 流式读取与背压;
  • 最大输出、超时和静默超时;
  • PTY 与非 PTY 差异;
  • 前台转后台、查询、写 stdin、终止;
  • 终止整个进程组而不只杀父进程;
  • exit code、signal、duration 的结构化返回;
  • ANSI 清理、二进制输出、编码错误;
  • Windows / POSIX 差异;
  • 密钥脱敏和日志策略。

asyncio 取消一个 Shell 工具时,怎样确保没有孤儿进程?#

子进程应运行在独立 process group/session 中。收到取消后先发温和终止信号,等待短暂 grace period,再强制杀进程组;同时继续 drain 管道,避免子进程因 pipe 满而卡住。取消路径写入结构化结果并 await 清理完成。父协程、输出泵和超时任务使用结构化并发统一收束,不能创建无人管理的 background task。

命令输出 2GB 怎么办?#

执行层不能无限缓存。采用 ring buffer + 流式落盘:UI 可看实时窗口,模型只收到头尾、关键诊断和“已截断”标记,完整输出存 artifact 并给句柄。对编译/测试输出可做解析器,优先抽取失败用例、错误位置和摘要。限制既按字节也按 token 估算。

Git 工作流#

Agent 应理解:

  • worktree 是否干净、哪些改动属于用户;
  • tracked / untracked / ignored 的区别;
  • diff、staged diff、基线 commit;
  • 分支、worktree、merge conflict;
  • 不应擅自 commit、push、丢弃用户更改;
  • 测试生成物与真正代码改动的区分。

一个安全流程:

记录基线和初始 dirty diff
→ 修改
→ 检查 diff 范围
→ 运行目标测试
→ 运行更广回归
→ 再次检查 diff
→ 向用户报告修改、验证、未解决风险

如何判断 Agent 有没有“作弊”通过测试?#

  • 检查是否修改/删除测试、fixture、配置和断言;
  • 运行隐藏测试或独立 verifier;
  • 检查生产代码是否硬编码样例;
  • mutation testing:改变输入是否仍满足语义;
  • 比较任务前后测试发现数量;
  • 静态规则检测 skip、xfail、异常吞噬;
  • 让第二个 verifier 只看需求、diff 和测试证据;
  • 评测环境将测试目录设为只读。

MCP:协议层而不是智能层#

MCP 将外部能力标准化为客户端/服务端协议,常见原语包括:

  • Tools: 模型可选择调用的动作;
  • Resources: 可读取的上下文资源;
  • Prompts: 可发现的提示模板;
  • 以及能力协商、生命周期、通知和不同 transport。

MCP 解决了什么,没解决什么?#

它解决连接与互操作:工具发现、schema、调用和结果传输可以跨产品复用。它不自动解决工具质量、权限、安全、正确选择、上下文污染和任务成功。Host 仍负责信任、授权、隔离、用户同意、输出限制和审计。协议兼容不等于语义可靠。

接入一个第三方 MCP Server 要做哪些防护?#

  • Server 身份、来源和版本固定;
  • 工具清单与 schema 变更检测;
  • 每工具最小权限、网络和文件范围;
  • 展示真实调用目标,避免相似名称欺骗;
  • 工具描述与返回内容都视为不可信数据;
  • 防 prompt injection、数据外传和跨工具组合攻击;
  • OAuth token 按 server、用户和 scope 隔离;
  • 设置超时、输出上限、速率限制;
  • 记录 server/version/tool/call/result 审计链;
  • 高风险调用要求用户确认。

官方规范强调 Tools 是模型控制的能力,但生产 Host 仍必须保留策略控制。参见 MCP Tools 规范


仓库级上下文工程#

核心认识#

上下文工程不是“把更多代码塞进窗口”,而是:

在每个决策时刻,用有限 token 提供最能改变正确动作概率的信息,同时保留来源、时效和结构。

上下文至少有六类:

  1. 用户目标、验收条件与约束;
  2. 仓库规则:AGENTS.md、README、贡献规范、构建配置;
  3. 代码与符号;
  4. 运行反馈:错误、测试、日志、Git diff;
  5. 轨迹状态:计划、已尝试动作、关键决策;
  6. 工具 schema、权限、环境能力。

仓库探索流水线#

flowchart LR Q[任务与当前错误] --> A[意图/实体提取] R[仓库] --> I[离线/增量索引] A --> H[候选召回] I --> H H --> L[词法/符号/图/向量混合排序] L --> D[去重、邻域扩展、依赖扩展] D --> B[Token Budget 分配] B --> C[带路径、行号、版本的上下文] C --> M[模型决策] M --> F[工具反馈与新线索] F --> A

第一步:快速建立仓库地图#

  • 根目录和主要子目录;
  • 语言、包管理器、构建系统;
  • 入口点、测试目录、配置;
  • 仓库级指令文件;
  • Git 状态、当前分支、最近相关提交;
  • 大文件、生成目录、vendor、锁文件。

不要一开始递归读取所有文件。先形成结构假设,再按任务补证据。

第二步:混合召回#

信号 优点 缺点
文件名/路径 快、精确、可解释 用户未给出名称时弱
词法搜索 BM25/rg 标识符和错误文本极强 同义表达弱
符号/LSP/AST 定义、引用、类型结构准确 多语言与构建成本
依赖/调用图 能扩展到上下游 动态语言不完整
向量检索 语义召回 易召回“像但无关”的代码
Git history/blame 解释设计原因与相关改动 噪声和成本高
测试关联 接近验收语义 映射可能隐式

对代码仓库,词法和符号通常应是主干,向量是补充而不是默认答案。

一种排序表达:

score =
  w1 * lexical_match
+ w2 * symbol_relation
+ w3 * path_prior
+ w4 * recency_or_diff
+ w5 * semantic_similarity
+ w6 * test_failure_proximity
- w7 * generated_or_vendor_penalty
- w8 * redundancy

权重应由真实任务离线学习或调优,并在 trace 中记录每个片段为何入选。

第三步:邻域扩展#

召回一个函数后,通常还需要:

  • 定义所在类/模块的结构摘要;
  • 调用方与被调用方;
  • 类型、协议、配置;
  • 对应测试和 fixture;
  • 最近错误堆栈涉及的路径;
  • 仓库指令和局部约定。

但扩展必须有预算,不能沿调用图无限展开。

Token 预算#

可以把窗口看作预算而不是容量:

可用输入 = context_window
         - 预留输出
         - system / policy / tool schemas
         - 安全余量

动态分配示例:

区域 初始比例 备注
目标、约束、当前计划 10% 高优先级,不能压没
当前相关代码 35% 随任务变化
测试/错误/运行结果 20% 越接近验证阶段越高
历史摘要和关键决策 15% 保留失败教训
工具 schema / 环境 10% 可按需加载
安全余量 10% 防 tokenizer 和 provider 差异

模型窗口足够大,为什么还要检索和压缩?#

大窗口不等于有效注意力无限。无关代码会稀释关键信号,增加延迟和成本,还会引入过时版本与相似实现的干扰。上下文工程优化的是信息密度、时效和因果相关性。即使窗口装得下,也应只放对当前决策有帮助的内容。

上下文压缩#

压缩分层:

  1. 无损裁剪: 删除重复 tool 输出、ANSI、成功日志、base64;
  2. 结构化提取: 测试只保留失败摘要和关键堆栈;
  3. 语义摘要: 把旧轮次压成 handoff summary;
  4. 外部化: 大输出、完整 diff、媒体存 artifact,只放引用;
  5. 重新检索: 不把旧代码永久摘要,必要时从当前工作区再读。

一个可靠 handoff summary 至少包含:

## 当前目标
## 验收条件与约束
## 已完成及证据
## 当前工作区改动
## 关键决策及原因
## 失败尝试与错误签名
## 未解决问题
## 下一步
## 必须重新读取的文件/产物

如何评测压缩质量?#

不要只看摘要“通顺”。可以做:

  • 恢复任务成功率: 新 Agent 只拿摘要能否继续完成;
  • 关键事实 recall: 目标、约束、路径、错误、决策是否保留;
  • 矛盾率/陈旧率: 摘要是否与当前 workspace 冲突;
  • 压缩率与成本: token 减少多少,新增调用多少;
  • 下游动作差异: 原上下文和压缩上下文的下一步是否一致;
  • 故障注入: 特意放入被否决方案,看摘要是否错误复活它。

context overflow 怎么恢复?#

在发请求前做 token 预估和软阈值压缩;若 provider 仍返回 overflow,捕获为专门错误,减少预留输出或进行更激进压缩后有限重试。压缩过程本身也需要输出上限。修复消息结构时必须保持 tool call/result 配对和 provider 的角色约束,否则会从 overflow 变成 400。

Kimi Code 公开 changelog 中能看到许多真实边界:上下文溢出后压缩重试、严格 provider 的工具调用相邻约束、压缩摘要保留最新意图/关键结果/开放问题等。可以继续追踪 Kimi Code 公开仓库 的最新变更,但应把它作为工程案例而不是背诵题。

Memory:不要把数据库叫成记忆就结束#

类型 内容 生命周期
Working memory 当前目标、计划、最近观察 单 turn/session
Episodic memory 历史任务、决策、结果 跨 session
Semantic memory 仓库约定、用户偏好、领域知识 长期、可更新
Procedural memory Skill、工作流、工具使用方法 长期、版本化

写入长期记忆前要回答:

  • 这是事实、偏好还是一次性状态?
  • 来源和时间是什么?
  • scope 是用户、仓库、分支还是机器?
  • 何时过期、如何纠错?
  • 是否含密钥、隐私或恶意注入?
  • 相互冲突时谁优先?

为什么不能把所有对话都向量化后检索?#

历史对话含大量临时假设、失败输出和过期代码,语义相似不代表当前正确。长期记忆需要写入门控、来源、scope、TTL 和冲突处理;关键事实更适合结构化存储。检索结果应标注为“历史线索”,需要用当前仓库验证。

Prompt Engineering 的工程化#

好 prompt 不只是措辞,而是运行契约:

  • 身份与目标;
  • 权限和禁止项;
  • 可用工具及使用边界;
  • 仓库局部指令;
  • 任务验收条件;
  • 输出/进度协议;
  • 不确定时何时询问;
  • 完成前验证要求。

常见失败:

  • 指令冲突,没有优先级;
  • prompt 太长,关键规则埋没;
  • 用自然语言重复 Runtime 已能强制的规则;
  • 示例与当前工具 schema 过期;
  • 要求“永远”“必须”但无程序约束;
  • 把工具返回的不可信文本和系统指令混在一起。

Prompt、Tool、Runtime 三者如何分工?#

能由 Runtime 硬约束的安全与状态不变量,不只靠 prompt;需要结构化输入输出的能力放在 Tool;需要模型做语义判断、策略选择和风格控制的部分放 Prompt。比如“不要删除用户文件”应有策略层保护,“搜索代码”应有工具,“优先先读仓库规范”可由 prompt 引导并由 trace 评测。

从“有一个工具”到“工具契约”#

只定义 JSON Schema 还不够。对 Runtime 来说,一个可生产使用的工具至少要声明六类语义:

name: apply_patch
schema_version: 4
effect:
  type: write
  scope: workspace
concurrency:
  conflict_key: "file:{path}"
idempotency:
  mode: compare_and_swap
approval:
  risk: medium
result:
  max_inline_bytes: 16384
  full_output: artifact
recovery:
  reconcile: compare_file_hash
  • effect 决定策略和审计,不应由模型自己描述;
  • conflict_key 让调度器知道哪些动作必须串行;
  • idempotency 决定超时或崩溃后能否安全重试;
  • approval 表达的是能力风险,而不是 UI 文案;
  • result 约束进入模型上下文的体积;
  • recovery 告诉 Runtime 如何确认未知结果。

我会把工具注册分成两步:启动时验证静态契约,执行时再结合 session 权限、workspace revision 和具体参数生成一次 ExecutionPlan。这样同一个 run_command 在只读容器和用户宿主机上可以有不同策略,但工具名称与模型理解保持稳定。

文件编辑为什么要把“意图”和“补丁格式”分开?#

模型输出 unified diff 只是表达修改意图的一种编码,不应该成为内部真相。Runtime 可以先把它规范化为:

EditIntent:
  path
  base_hash
  expected_regions[]
  replacement_regions[]
  newline_policy
  file_mode_policy

之后再选择 patch、CST、LSP WorkspaceEdit 或整文件写入。这样做的价值是:冲突检测、审计和 replay 围绕稳定语义,而不是绑定某种模型最容易生成的文本格式。

一个上下文装配实例#

仍以“鉴权缓存并发刷新重复请求”为例。初始任务只给出一句自然语言,仓库有 8 万个文件。第一轮召回可能得到:

候选 召回理由 是否立即进入上下文
src/auth/cache.py refresh_token 符号定义 是,读取完整类和相邻辅助函数
src/client.py 调用 refresh_token 是,只取调用路径和错误处理
tests/auth/test_cache.py 同名模块测试 是,优先看并发相关 fixture
docs/auth.md 语义相似 只取公开行为约束
legacy/auth/cache.py 文本命中更高 否,路径带 legacy 且无当前调用边
vendor/oauth/cache.py embedding 很相似 否,第三方代码降权
最近一次相关 commit 修改过锁语义 摘要进入,必要时展开 diff

上下文构建器不是把排序前 K 个片段拼起来,而是按角色分配名额:

目标与禁止项             1.5k tokens
仓库规则与公开 API         1k tokens
核心定义                  5k tokens
调用方与数据流             4k tokens
测试和失败日志             4k tokens
当前 diff / plan / history  3k tokens
工具与输出预留             6k tokens

如果测试刚刚失败,错误堆栈和相关 fixture 的优先级应立即高于历史文档;如果进入最终验证阶段,当前 diff 与 acceptance 又应取代探索阶段的大量 repo map。这说明 context packing 是阶段相关的调度问题,不是一次性的 RAG。

怎样知道模型真的使用了召回内容?#

仅有 recall@k 不足以说明上下文有效。我会结合三类证据:

  1. 干预: 移除某片段或换成 oracle 片段,观察动作和最终成功率变化;
  2. 行为: 下一步工具参数、patch 和解释是否能追溯到该片段;
  3. 反事实: 放入一个高相似但错误的旧实现,检查模型是否被污染。

最终关注的是 marginal success gain per token:一个片段多占 2,000 token,却不改变任何决策,就不应因为“相关”而长期驻留。

压缩不是摘要写作,而是状态迁移#

压缩前后的 session 不要求逐字等价,但必须在关键行为上等价。我会把下面几类信息当作不可丢失字段,而不是交给自由文本摘要碰运气:

goal:
acceptance:
active_constraints:
workspace_revision:
open_tool_calls:
decisions:
  - claim:
    evidence_refs:
    status: accepted | rejected | tentative
failed_attempts:
  - error_signature:
    do_not_repeat:
next_actions:
artifact_refs:

尤其是“已经否决的方案”必须有一等表示。很多长任务不是忘记正确答案,而是压缩以后把旧错误重新当成新想法。恢复测试应该故意构造这种场景:压缩前否决方案 A,压缩后给出相似线索,检查 Agent 是否再次执行 A。