加载中...
  • Just-In-Time Context:信息不是越早塞越好 loading

    吃透 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 只解决进入时机,不自动解决退出。第一阶段读取的登录源码,在任务转到文档发布后可能已经无关;若每轮继续携带,长会话仍会回到全量上下文。

    每个动态片段可以带 introducedAtSteplastUsedAtStep、source hash 与 eviction policy。当前任务节点完成后,文件正文退出 active context,只在 transcript/artifact 留引用;后来再次需要,按 hash 判断是否重读。用户明确约束和未完成任务不按“最近没用”淘汰。

    Context manifest 记录进入与退出原因:required-by-taskreplaced-by-newer-readtask-completedoffloadedstale。如果一个 30KB 文件连续十轮没有被工具、回答或任务引用却仍常驻,报告为 context debt。

    这类治理不能只靠模型总结“哪些不需要”。文件版本、任务依赖和 artifact 可恢复性可以确定性判断;自然语言讨论再交给压缩器。先移除可恢复大块,再压缩剩余历史,损失更小。

    延迟读取也有 SLA,不能让用户等成串行瀑布

    按需读取可能造成“模型想一下,读一个文件,再想一下,再读一个文件”的瀑布。对高置信依赖可以受控预取,对同一目录的多个小只读读取可以并发,对远端检索设置连接 timeout 与 fallback。

    观察 time-to-first-action、JIT 工具轮数和总完成时间。若 token 下降 20%,总时延却翻倍,需要合并读取或改进索引;若全量预加载只快 500 毫秒却增加 60K token,延迟收益不值成本。

    JIT 策略最终是在三项之间平衡:信息噪声、获取延迟、证据新鲜度。不同任务权重不同,不能用一个“永远晚加载”的规则覆盖全部场景。

    本文目录
    本文目录