吃透 AI Agent 开发 · 第 19 篇 · 第四章 · Context Engineering
启动时把所有文件、工具和记忆都塞给模型,会让上下文拥挤且陈旧。按需读取更接近真实工作。
启动时把所有文件、工具和记忆都塞给模型,会让上下文拥挤且陈旧。按需读取更接近真实工作。
资料应该在什么时候出现
工程师接到一个陌生项目,不会把整个硬盘打印在桌面上,而是先看任务,再打开相关目录,遇到新名词才搜索。Agent 也需要这种节奏:启动时给稳定规则和很小的索引,真正需要文件、工具或记忆时再取原文。
过早注入有三个成本:占窗口、增加选择噪声、把旧资料伪装成当前事实。JIT Context 不是少给模型信息,而是把“什么时候给”变成一个可测试的决策。
sequenceDiagram
participant U as 用户任务
participant C as 当前上下文
participant I as 索引
participant R as 按需读取
participant M as 模型
U->>C: 只放稳定规则和任务目标
C->>M: 请求下一步
M->>I: 查询文件/工具/记忆索引
I-->>M: 候选与来源摘要
M->>R: 选择一个候选读取原文
R-->>C: 带预算和新鲜度的事实
C->>M: 继续判断
索引不是正文的替代品
索引只需要回答“可能相关的东西在哪里”,正文读取才提供可以引用的事实。索引项应包含路径、名称、摘要、更新时间和权限范围;正文进入模型前,还要重新做路径检查和预算裁剪。
interface Candidate {
path: string
summary: string
updatedAt: number
access: 'readable' | 'blocked'
}
function shouldRead(candidate: Candidate, now: number): boolean {
const freshEnough = now - candidate.updatedAt < 7 * 24 * 60 * 60 * 1000
return candidate.access === 'readable' && freshEnough
}
过期不代表删除,意味着读取时要重新确认。对项目配置、权限规则和运行状态尤其如此:摘要可以帮助找到位置,却不能证明当前值仍然有效。
四个时间点的取舍
| 信息 | 启动时 | 模型选工具前 | 工具执行前 | 结果回填时 |
|---|---|---|---|---|
| 稳定工具纪律 | 是 | 否 | 否 | 否 |
| 工具完整 schema | 否 | 是 | 可按需补充 | 否 |
| 文件正文 | 否 | 只给摘要 | 是 | 只保留结果证据 |
| 长期记忆 | 只给索引 | 按任务选择 | 不应自动扩大 | 标记是否验证 |
q-code 中追踪按需读取
| 顺序 | 路径 | 要观察的边界 |
|---|---|---|
| 1. 文件索引缓存 | src/mentions/file-index-cache.ts |
索引刷新失败时是否保留旧值 |
| 2. 文件读取工具 | src/tools/file-tools.ts |
读取是否仍限制在工作区 |
| 3. 动态运行上下文 | src/context/runtime-context.ts |
哪些事实只进本轮请求 |
| 4. 工具搜索 | src/tools/tool-search-tool.ts |
工具 schema 何时进入工作集 |
一个反直觉的失败场景
启动时给模型全量文件列表,看起来提高了“知道项目”的能力,实际会让它更容易引用同名旧文件。更糟的是,索引更新和模型请求并发发生,模型拿到的摘要可能已经过期,却没有任何年龄提示。
修复要区分三种状态:候选存在、正文已读取、正文已验证。只有第三种状态才能支撑高风险决定;读取失败时保留失败原因,不要用索引摘要冒充文件内容。
让信息时机成为实验变量
练习:为同一个“修改配置并运行测试”的任务准备三种上下文策略:启动全量、只给索引、按需读取。记录模型第一次选择工具的时间、无关文件引用数、总 token 和最终修改是否正确。再让索引故意过期,确认模型会重新读取而不是继续相信旧摘要。
把“是否现在读取”写成决策,而不是模型直觉
候选资料是否进入本轮,可以考虑相关性、体积、新鲜度、读取成本、权限和错误代价。高风险修改依赖的配置,即使读取多花一步也值得;一份 200KB 的旧设计文档,只因标题相似就全量注入并不划算。
interface ContextDecision {
candidateId: string
action: 'inject-summary' | 'read-now' | 'defer' | 'reject'
reason: 'required-evidence' | 'high-relevance' | 'too-large' | 'stale' | 'forbidden'
budgetTokens?: number
}
记录 decision 后,模型选错文件时可以区分:索引没召回、策略推迟了、权限拒绝了,还是模型看到了候选却没读取。没有这层证据,所有问题都会被归为“检索效果不好”。
负面检索结果也要告诉模型
搜索 auth timeout 没找到匹配,不等于项目没有相关实现。索引可能过期、忽略目录配置错误、权限不足或查询词太窄。工具结果应返回 searched scope、index age、ignored roots 和是否截断。
matches: 0
scope: current workspace
index_age: 18m
ignored: node_modules, dist, vendor
truncated: false
suggestion: try symbol LoginTimeout or refresh index
这样模型能调整策略,而不是把空结果当成事实。对于权限拒绝,不能泄露被隐藏文件名,只说明当前范围不可访问。
文件索引允许旧,文件正文必须现读
启动时用缓存索引能减少等待,后台 watcher 再刷新。旧索引只用于展示候选;模型真正引用文件前,read_file 从磁盘读取当前内容并记录 hash。即使索引摘要说“使用 Jest”,当前 package.json 已改成 Vitest,正文证据仍应覆盖摘要。
watcher 可能丢事件或遇到权限错误。刷新失败时保留旧索引并标记 stale,不能把候选列表清空;也不能继续显示“最新”。用户输入不应被索引更新阻塞。
重命名和 symlink 需要 realpath 校验。候选来自工作区内缓存,不代表当前解析仍在工作区,读取时重新检查。
@file 是用户显式提高相关性的信号
用户输入 @src/auth.ts,说明该文件应优先进入本轮,但仍要校验路径、大小和类型。显式引用不等于允许绝对路径;工作区外引用只有在单独开关和审计下才可用。
大文件可以注入标题、关键片段和截断说明,保留继续读取的方法。多个引用共享总预算,不能让最后一个文件静默消失;manifest 说明每个文件实际注入范围。
图片附件同样是 JIT 上下文。原始二进制进入当前模型请求,transcript 只留脱敏摘要,下一轮若仍需要应由附件句柄重新加载,而不是永久把 base64 写进会话。
工具也是上下文,JIT 原则同样适用
模型只有看到工具 schema 才能可靠调用。启动只给目录摘要,任务命中后加载 5 个工具详情,等于对工具做 JIT。工作集变化属于动态尾部,不应插进稳定 system 前缀。
但核心常用工具每次都先搜索会增加步骤。可按任务分布保留一小组常驻只读工具,远程、低频或高风险工具按需出现。JIT 的目标是合适时机,不是把所有信息都延迟到最晚。
预取要基于高置信信号
用户说“修登录测试”,系统可以并行准备 package.json 脚本摘要、auth 目录候选和最近失败测试索引,模型首轮就能更快行动。但预取若读取整个仓库,重新变成全量注入。
预取结果先进入缓存,不自动进 Prompt。模型选择需要时再注入;不需要的结果不会占上下文。预取任务受低优先级、超时和取消控制,不能和真正工具争抢大量资源。
可以从历史 Eval 找高置信模式:90% 的同类任务都会读取 package.json,适合轻量预取;只有 10% 需要完整依赖树,就继续 defer。
信息过期要在使用时再次判断
候选在 10:00 检索,用户 10:05 修改文件,模型 10:06 准备写入。即使初次读取时新鲜,写前也应再次读取目标或比较 hash。JIT 不只是“晚一点加载”,也包括在关键副作用前重新验证。
记忆的 age、远程文档的 ETag、数据库结果的 snapshot time 都可以帮助。没有可比较版本时,结果应注明采集时间,模型在高风险决定前主动刷新。
用漏斗看 JIT 是否真的减少噪声
记录一条信息从目录候选、被选中、读取原文、注入模型、被最终回答引用的漏斗。若 100 个候选读取了 60 个,最终只用 2 个,策略过于激进;若目标文件经常不在 top-k,索引召回不足。
同时统计额外工具轮数和任务成功率。JIT 不是把 token 压到最低,而是在少量额外读取与更干净上下文之间找到平衡。高风险任务宁可多验证一步,简单问答则可以直接用新鲜摘要。
成熟的 JIT Context 让模型知道三件事:当前看到了什么,哪些内容只是候选,缺少证据时怎样继续取得。它不会把“未注入”伪装成“不存在”。
一次登录故障的 JIT 时间线
用户只说“昨天还好好的,今天登录超时”。启动阶段提供项目规则、当前分支摘要和文件索引年龄;没有立即读取整个 src。模型先用 grep 找 LoginTimeout,命中 src/auth/client.ts 与一份旧迁移文档。
策略看到源码当前更新、文档 180 天未验证,先读取源码和相关测试,只给旧文档标题。源码引用 config.authTimeoutMs,第二轮再读取配置;测试报错指向环境覆盖,第三轮才按需查看 .env.example,真实 .env 仍因敏感策略不可见。
t0 inject: project rules + task + index(age=3m)
t1 search: LoginTimeout -> 2 files + 1 stale doc
t2 read: auth/client.ts, auth/client.test.ts
t3 read: config/defaults.ts, .env.example
t4 decision: current timeout value + test evidence
如果 t0 就注入全仓库,旧迁移文档可能抢走注意力,真实 .env 还带来泄密风险;若只给索引摘要,模型又无法证明当前配置。JIT 的关键是每次新读取都由上一条证据提出具体缺口。
这条时间线也适合做 Eval:断言首轮不读无关目录,不访问 .env,目标源码在修改前已读取,旧文档只作为 stale 线索,总输入 token 比全量策略低,同时最终修改正确。这样“按需”不是口号,而是可观察行为。
警惕“上下文债务”:读进来以后一直不退出
JIT 只解决进入时机,不自动解决退出。第一阶段读取的登录源码,在任务转到文档发布后可能已经无关;若每轮继续携带,长会话仍会回到全量上下文。
每个动态片段可以带 introducedAtStep、lastUsedAtStep、source hash 与 eviction policy。当前任务节点完成后,文件正文退出 active context,只在 transcript/artifact 留引用;后来再次需要,按 hash 判断是否重读。用户明确约束和未完成任务不按“最近没用”淘汰。
Context manifest 记录进入与退出原因:required-by-task、replaced-by-newer-read、task-completed、offloaded、stale。如果一个 30KB 文件连续十轮没有被工具、回答或任务引用却仍常驻,报告为 context debt。
这类治理不能只靠模型总结“哪些不需要”。文件版本、任务依赖和 artifact 可恢复性可以确定性判断;自然语言讨论再交给压缩器。先移除可恢复大块,再压缩剩余历史,损失更小。
延迟读取也有 SLA,不能让用户等成串行瀑布
按需读取可能造成“模型想一下,读一个文件,再想一下,再读一个文件”的瀑布。对高置信依赖可以受控预取,对同一目录的多个小只读读取可以并发,对远端检索设置连接 timeout 与 fallback。
观察 time-to-first-action、JIT 工具轮数和总完成时间。若 token 下降 20%,总时延却翻倍,需要合并读取或改进索引;若全量预加载只快 500 毫秒却增加 60K token,延迟收益不值成本。
JIT 策略最终是在三项之间平衡:信息噪声、获取延迟、证据新鲜度。不同任务权重不同,不能用一个“永远晚加载”的规则覆盖全部场景。