上下文与安全

Article / 上下文与安全

上下文工程与长会话

长会话 Agent 的上下文管理:大结果溢写、阈值压缩、三层记忆与 prompt caching 的具体做法与取舍。

NovaCode 上下文工程压缩记忆Prompt Caching

编程 Agent 的瓶颈,很多时候是它在一次会话里能「记住」的东西太少。读一个文件、跑一次测试、搜一个符号,工具输出动辄成千上万字;几十轮之后,模型的上下文窗口(context window,指一次请求里模型能看到的最大文本量,用 token 计量)就被塞满了。真实的长任务里,Agent 需要同时记住任务目标、项目规矩、最近的报错和几天前的决定,而窗口是有限的——怎么分配它,就是上下文工程要解决的问题。

NovaCode 是一个终端里的编程助手,它的上下文体系分两层:工具结果进入历史之前先控制体积,接近窗口上限时再压缩早期历史;跨会话的延续由三层记忆承担。这套机制的目标是让每一份留在窗口里的信息都有保留的理由。

上下文窗口的成本

按「历史只追加、越多越好」的直觉来管理上下文,代价是三重的:成本按 token 计费,历史越长每轮请求越贵;延迟随上下文增长;质量则会被稀释——检索增强里有个经典现象叫 lost in the middle,说的是内容太多时,位置居中的部分最容易被模型忽略。对 Agent 来说,工具输出里大段与当前决策无关的内容,既是成本也是噪声。这也是「把整个代码仓库塞进上下文」在实践中很少奏效的原因:无关文件越多,相关的那几段越难被持续关注。

所以 NovaCode 的上下文管理是「两层机制加三层记忆」的体系:第一层在单个工具结果进入历史之前控制体积,第二层在总量接近上限时做摘要压缩,记忆体系负责跨会话的延续。每一层都在回答同一个问题:什么信息必须留在窗口里,什么信息可以放到窗口外,但保留一条随时能回来的路。

大结果溢写与回读

单条工具结果超过 50,000 字符时,完整内容会被写到会话目录下的 tool-results/<tool_use_id>.txt 文件,历史里只保留一个 <persisted-output> 占位:前 2,000 字符、文件路径与大小。一轮里如果有多个工具结果,还有一道聚合预算:同一条消息里的总字符超过 200,000 时,按长度从大到小逐条溢写,直到回到限额内——优先动最大的,需要动的条数最少。

这和「截断」有区别。截断是不可逆的信息损失,模型无从知道被砍掉的部分里有什么;溢写只是把信息移出窗口,但路径留在历史里,模型需要细节时可以自己用文件读取工具把内容读回来。配套的细节是:对溢写文件的回读结果会被豁免,不再二次溢写,否则模型会在「读回、溢写」之间打转;写盘失败的结果也会被标记豁免,避免对着同一块坏磁盘反复重试。这里的原则是:不替模型决定哪些信息没用,把信息放在它够得着的地方,需要时由模型自己读取。

接近窗口上限时压缩历史

第二层是阈值压缩。触发线是「上下文窗口减去约 20,000 token 的摘要输出预留,再减去安全边距」;其中留 13,000 token 的软触发线走熔断器保护,留 3,000 token 的硬触发线则强制压缩、绕过熔断——已经贴着窗口上限时,宁可冒一次压缩失败的风险,也不能让请求直接失败。熔断器连续失败三次后打开,软触发区间内的自动压缩会停止重试,不再反复消耗调用。

Token 用量不需要每轮重新数,靠「真实用量锚点加增量估算」:每轮 assistant 消息入历史后记录一次服务端返回的真实用量,之后新增的消息按约 3.5 字符/token 估算。压缩的保留策略是「摘要前缀加原样保留尾部」:从尾部往前累计,至少保留 10,000 token 或 5 条消息,但不允许超过 40,000 token——最后一条约束防止单条超大消息把保留区撑爆;如果保留边界恰好切在一条带 tool_result 的消息上,会向前多退一条,保证 tool_use 与 tool_result 这对配对不被拆开。还有一个止损条件:如果可压缩的前缀不足 2,000 token,直接放弃——摘要本身的开销比回收的空间还大。直接在边界处砍掉历史是最便宜的做法,但任务目标与既有结论这类无法从尾部重建的信息会一并消失;摘要加逐字尾部的组合,是在成本与连续性之间取的平衡。

压缩产物由三部分组成:一条摘要 user 消息、一份恢复附件,以及原样保留的尾部。恢复附件包含四小节——最近读过的 5 个文件内容、已激活 Skill 的标准流程、当前可用工具清单,以及一条「以上是重建的上下文,需要原文请重新读取」的提示;摘要消息里还带着完整会话记录(transcript)的路径,模型可以按需回读细节。压缩成功后,环境与记忆的注入标记被重置,下一轮重新注入,用量锚点也清空等待新的真实数据。

这套设计承认压缩有损失,因此重点放在控制损失的规模并保留恢复路径:摘要记录发生过什么,逐字保留的尾部保证最近的细节不失真,恢复附件用来重建环境。三者缺一,恢复后的 Agent 就很难接着干活。

三层记忆的分工

上下文管理解决的是「这一次会话」,记忆解决的是「上一次」。NovaCode 把记忆分成三层,每层回答一个不同的问题。

指令文件回答「这个项目有什么规矩」。加载器按优先级发现并拼接用户全局的约定文件、从 git 根到工作目录每一层的项目约定文件,以及工作目录的本地覆盖;文件里的 @./path 引用语法会展开被引用的文件,支持最多 5 层深度、按绝对路径去重防循环、跳过代码围栏内的引用。它随每轮请求常驻,也因此不适合承载易变信息——常驻意味着持续付费,适合放在这里的是规矩,会变的状态不适合。

自动记忆回答「用户偏好与上次结论是什么」。记忆按类型路由目录:userfeedback 写到用户主目录、跨项目跟随;projectreference 写到项目目录。每条记忆是一个带 frontmatter 的 Markdown 文件:

---
name: prefers-small-commits
description: 用户偏好原子提交,一次提交只做一件事
type: feedback
---
提交前先把改动按主题拆分,每个 commit 单一职责。

索引文件只是目录,注入内容仍来自各条记忆,索引限制在 200 行、25 KB 内,超限截断并附警告。提取由主循环在每轮没有工具调用时触发——模型把最近的对话文本按固定格式总结成记忆块;如果上一次提取还没结束又触发了,不会并发启动,而是标记尾随执行。召回则是「LLM 选择式」:扫描两个目录的清单(最多 200 个文件、每个只读前 30 行解析 frontmatter),让模型选出最多 5 条相关记忆,渲染成注入块;在 TUI 里它是非阻塞预取,用户消息一发出就并发跑一个 8 秒超时的选择器,失败就静默忽略,绝不拖慢主对话。后台还有整理任务,用一组门控决定何时合并重复、删除过期:距上次整理至少 24 小时、期间至少 5 次会话、扫描节流 10 分钟,再加一把可接管陈旧锁。

会话记录回答「上次做到哪」。每次启动创建一个会话目录,JSONL 追加记录对话,元信息另存;每条记录内联工具调用与结果,与模型协议无关,换一个 provider 也能恢复;thinking 块不落盘,因为它的签名只在同一轮工具循环里有意义;压缩边界作为一条结构化的 compact_boundary 记录写进 JSONL,恢复时只重放它之后的记录,之前的原始前缀留在磁盘供审计。恢复后如果出现「有 tool_use 没有 tool_result」的悬空配对,发请求前会统一补齐——配对校验是协议层的硬要求,缺失就是 400。

三层记忆的注入方式刻意不同:指令文件常驻,记忆按需召回,会话记录只在恢复时加载。这比「把所有东西塞进 system prompt」复杂,但让每类信息的生命周期与成本都落在合理的位置。

prompt caching 与稳定前缀

长上下文还有一个成本杠杆:prompt caching(提示词缓存)。Anthropic 系列的模型允许在提示词里标记缓存断点,断点之前的稳定前缀会被缓存,后续请求命中缓存的部分按正常价格的 10% 计费。NovaCode 在三个最长稳定前缀上打点:system 块、工具数组的最后一个工具,以及最后一条 user 消息的尾部块。工具数组的标记有个容易忽略的细节——先浅拷贝再打标,避免污染注册表里的工具 schema 单例。

断点之后的字节能保持稳定,是因为工具结果在进入历史时就已经定型,不会被后续处理改写;system 提示词里则刻意只放与具体项目无关的内容。这条「缓存前缀不动」的约束反过来影响了许多设计:MCP 工具的加载策略、ToolSearch 是否暴露某个工具,都要考虑「改动会不会作废缓存」——上线一个新工具或改一次 system 提示词,都是成本决策。usage 统计层也做了归一化:Anthropic 的缓存读取与写入是独立计数,OpenAI 系列要把 cached_tokens 从输入里减掉,不同 provider 的口径才能加得起来。

窗口变大后的影响

业界常用的上下文策略可以归成几类:把记忆 RAG 化(用检索按需召回,不做全量注入)、把状态结构化(计划、任务清单外置成文件,不占对话历史)、用子 Agent 做隔离(子任务独立上下文,只把结论带回主会话)、按需读取(让模型自己决定读哪一段,不做预灌),以及给工具结果设分级预算。NovaCode 的溢写、压缩、记忆召回,都是这些策略在单机 Agent 里的具体形态。

当模型窗口从 200k 涨到百万级,这些机制仍然需要,只是参数会变。窗口变大只是把预算单位变大:成本仍然随 token 线性增长,缓存只是把命中部分降到十分之一,并不免费;延迟与注意力稀释依然存在,尤其是「中间内容被忽略」的问题不会因为窗口更大而消失。实际会变的是一些补偿性参数——保留尾部可以留得更长、摘要触发更晚、更多工具结果可以直接留在窗口里;而缓存断点带来的收益会更大,因为稳定前缀本身更长了。成本视角下,压缩阈值是一笔账:摘要调用成本与继续携带全文的成本之间如何取舍,只能用自己场景的流量与质量要求去算,并交给评测检验。

不变的一条原则是:窗口里的每一个 token 都要有留在那里的理由。机制可以随窗口大小调整,这条预算意识不会变。