上下文与扩展
工具结果先预算,再进入稳定历史;摘要保留原文尾部,MCP 定义按预算选择加载策略。
大工具结果先落盘
长会话的压力不只来自用户消息。一次搜索可以返回数百行,一次测试可以输出大量日志,MCP 工具定义也会占用模型输入。上下文管理因此需要同时关注工具结果、历史消息和工具 Schema,而不是只在报出窗口超限时删掉几条对话。
工具输出很大时,直接追加到对话会占用后续窗口并破坏可用性。NovaCode 在结果进入历史前,按预算将大结果保存为文件,模型上下文保留预览和回读路径。
def apply_tool_result_budget(
tool_results: list[ToolResultBlock],
session_dir: Path,
exempt_ids: set[str] | None = None,
) -> None:
"""在一轮工具结果进入对话历史之前执行聚合预算。
整批结果的总字符数超过 AGGREGATE_CHAR_LIMIT 时,从最大的开始逐条
溢写到磁盘、就地替换成预览,直到总量回到限额内。消息进历史前处理完,
历史里的内容自此不再改动,Prompt Cache 前缀天然稳定。
exempt_ids 里的 tool_use_id 不参与溢写:溢写文件的回读结果(再溢写
模型就永远看不到全文),以及本轮已经单条溢写过的结果。全是豁免项时
接受超额。
"""
exempt = exempt_ids or set()
total = sum(len(tr.content) for tr in tool_results)
if total <= AGGREGATE_CHAR_LIMIT:
return
# 按内容长度降序挑选:先溢写最大的,回到限额内需要动的条数最少。
ranked = sorted(tool_results, key=lambda tr: len(tr.content), reverse=True)
for tr in ranked:
if total <= AGGREGATE_CHAR_LIMIT:
break
if tr.tool_use_id in exempt:
continue
if len(tr.content) <= PREVIEW_CHARS:
# 比预览还短的结果,溢写换不回空间
continue
try:
fp = persist_tool_result(tr.tool_use_id, tr.content, session_dir)
except OSError:
# 写盘失败就保留原文。消息随即定型进历史,不会再有重试
continue
preview = make_persisted_preview(tr.content, fp)
total -= len(tr.content) - len(preview)
tr.content = preview
结果进入历史后保持稳定,有利于模型端的前缀缓存。落盘失败会保留原文,所有结果都豁免时也可能接受超预算;这是一种明确取舍,不是绝对硬截断。
为什么要保留回读路径
直接截断大日志会使错误位置或关键行永久消失。落盘后保留预览和路径,模型可以先判断是否需要完整内容,再通过读取工具获取相关部分。回读结果被豁免再次溢写,否则会陷入“读文件只得到另一个文件指针”的循环。
聚合预算对整批工具结果计算,而不只限制单个结果:十份各自不大的输出也可能一起挤满窗口。优先处理最大结果能用较少替换动作回收更多空间;无法写盘时保留原文,则优先保障信息可用性而接受体积超额。
这一层主要使用字符预算近似控制体积,不等价于模型 tokenizer 的精确 token 数。是否接近实际窗口,还需要结合模型窗口配置、用量反馈和下一层压缩阈值判断。
摘要与恢复不是删除全部历史
接近窗口阈值时压缩较早的前缀,保留近期逐字尾部。压缩边界要避开工具调用 / 结果配对,恢复附件补充最近读过的文件与已启用 Skill,避免只有摘要却不知道当前工作环境。
会话持久化保存压缩边界,恢复时校验工具配对。工具结果文件也需要随会话的生命周期管理,否则预览中的回读路径可能变成失效指针。
flowchart TD
T[工具结果批次] --> B[结果预算]
B --> P[预览与磁盘回读路径]
P --> H[稳定对话历史]
H --> C{接近窗口阈值}
C -->|否| M[继续模型调用]
C -->|是| S[较早前缀摘要]
S --> R[摘要、逐字尾部与恢复附件]
R --> M
压缩边界不能随意切
工具调用与结果是一组协议记录。保留调用却丢失结果,或只保留结果却丢失调用,都可能让下一次模型请求无效。压缩会调整保留起点,尽量保持这些配对;恢复时还要再次检查,不能仅因 JSONL 能读出就视为会话可用。
摘要保留目标、已完成工作和关键约束,近期尾部保留精确措辞,恢复附件补充最近文件和 Skill。三者一起减少压缩后的信息断层,但摘要依然有损,重要事实应以原文件和可回读结果核对。
压缩本身也依赖模型,可能超时或失败。项目使用失败熔断,避免每轮都为同一问题付出无效摘要调用;“自动压缩”不是无成本、永远成功的后台能力。
MCP 工具加载的三种路径
def decide_mode(
base_url: str,
context_window: int,
mcp_schema_chars: int,
threshold_percent: int = DEFAULT_EAGER_THRESHOLD_PERCENT,
) -> McpLoadingMode:
override = os.environ.get(_ENV_OVERRIDE, "").strip().lower()
if override in ("eager", "native", "dispatch"):
return McpLoadingMode(override)
if mcp_schema_chars <= 0:
# 没有 MCP 工具,走哪条都一样,eager 最省事
return McpLoadingMode.EAGER
budget = context_window * threshold_percent / 100
if estimate_schema_tokens(mcp_schema_chars) < budget:
return McpLoadingMode.EAGER
if is_official_anthropic_endpoint(base_url):
return McpLoadingMode.NATIVE
return McpLoadingMode.DISPATCH
| 模式 | 适用情况 |
|---|---|
| EAGER | Schema 预算足够,直接提供工具定义 |
| NATIVE | 大工具集,且端点支持对应原生策略 |
| DISPATCH | 大工具集通过发现和分发减少首轮定义占用 |
策略选择后还要保持会话工具定义稳定,避免每轮动态改变导致缓存失效。MCP 连接支持 stdio 与 Streamable HTTP,Skill 提供按需指令,Hook 提供生命周期扩展,它们不共享同一种授权语义。
为什么依据 Schema 预算
十个参数复杂、描述很长的工具,可能比几十个简单工具占用更多输入。加载策略估算 Schema 字符对应的 token 体积,并与窗口预算比较,再结合端点能力决定是否延迟加载。工具数量只能作为粗略线索,不能替代体积判断。
EAGER 把定义直接放进可用集合;NATIVE 使用端点支持的延迟工具能力;DISPATCH 通过发现与分发减少首轮暴露的定义。后两者减少固定开销,也可能增加查找步骤和调用成本,适合工具集大且首轮只需要少数工具的场景。
指令、记忆与扩展各自存什么
| 机制 | 保存或提供的内容 | 不应被混淆为 |
|---|---|---|
NOVACODE.md | 项目长期指令与约定 | 每轮任务的全部执行记录 |
MEMORY.md 与召回 | 可复用记忆索引及选择式信息 | 当前文件内容的绝对真值 |
| 会话 JSONL | 对话、工具配对与压缩边界 | 对存活进程的自动接管 |
| Skill | 按需工作方法,可 inline 或 fork | 绕过运行时权限的授权 |
| Hook | 生命周期上的检查与扩展动作 | 独立、无限制的 Agent 执行能力 |
这些机制共同改善长任务连续性,但存储生命周期和权限范围需要分别管理。特别是恢复历史不意味着重新执行所有旧工具,也不意味着此前启动的系统进程已被收养。
已知限制
Hook 的 agent 动作尚未实现,会明确失败;不能列为已具备能力。本站 mock 的 /compact 只保留最近四条演示消息,不调用模型生成摘要、不保留真实工具协议历史,也不模拟磁盘结果回读。真实能力应以这些源码与项目回归记录为准。
