跳到主要内容

上下文工程:让上下文有界又不丢信息(全书精华)

30 秒导读: Dexter 是个金融深度研究 agent,一次查询里可能连打几十个工具、抓回几百 KB 数据。 但模型的上下文窗口是固定的一格内存——塞满就崩。这一章讲 Dexter 怎么在长研究里既 不撑爆窗口、又不永久丢掉关键数字:靠一个 append-only 的 scratchpad 当"唯一事实源", 再叠上一套三级压缩梯度单结果治理。这是全书工程含量最高的一章。

本章只讲"上下文怎么被治理"。记忆 flush 把信息写进持久记忆的内部记忆系统; 主循环的骨架本身(一次 query 端到端怎么跑)见 核心主循环; 工具层的路由与权限工具与权限


1. 先搞懂问题:上下文窗口是一格会满的内存

1.1 一句话定义

上下文工程 = 在固定大小的 context window 里,决定"什么留、什么压、什么扔",让 agent 能一直研究下去而不崩、也不失忆。

模型每次调用,都要把"到目前为止的全部对话 + 工具结果"重新塞进一个有上限的窗口 (比如 200K token)。这个窗口就像一格内存:

  • 塞不下 → API 直接报 context overflow 错误,这一步作废。
  • 塞太满 → 就算没报错,模型也会"注意力稀释",在一堆旧数据里找不到重点。

1.2 为什么金融研究特别难

一次"帮我对比英伟达和 AMD 的最近四个季度基本面"这样的查询,会触发:

  • 十几次 get_financials / get_market_data,每次回来一大坨 JSON;
  • 几篇 read_filings(财报原文,单篇就能几万字符);
  • 若干 web_search / web_fetch 网页正文。

这些原始工具结果是上下文膨胀的头号来源。麻烦在于:里面的数字(营收、毛利率、增长率) 恰恰是最终答案要用的,不能随便扔。所以不能简单"删旧的",得在压缩的同时保住数字

1.3 心智模型:窗口是内存,scratchpad 是磁盘

Dexter 的核心思路可以用一句类比记住:

把 context window 当内存,把 scratchpad(JSONL 文件)当磁盘。

内存(喂给模型的那串消息)必须精简、有界;但所有工具结果的完整原文都先落在磁盘 (scratchpad)上,一份不丢。需要压缩时,是"从磁盘重新组织一份精简视图喂进内存", 而不是"把内存里的东西删掉就没了"。这条主线贯穿全章。


2. 顶层全景:四道防线 + 一个事实源

Dexter 不靠单一手段控上下文,而是一层不行上一层的纵深防御。先看全景图。

怎么读这张图: 上方是"唯一事实源"(所有原文都在这);下方从左到右是逐级加重 的四道防线,轻的先上,重的兜底。命中哪级取决于上下文有多满。

┌───────────────────────────────────────────┐
│ 唯一事实源:Scratchpad(append-only JSONL) │
│ 每个工具结果的完整原文都先落在这里,一份不丢 │
└───────────────────────────────────────────┘
↑ 写 ↓ 按需重组出"精简视图"喂给模型

喂给模型的消息数组(内存),受四道防线保护,由轻到重:

①单结果治理 ②微压缩 ③全量压缩 ④硬截断
单个结果太大? 旧的读类结果 窗口快满?LLM 把 还超?直接
→落盘+留预览 太多?→换成 所有结果摘成9段结构 删最旧的几轮
每turn总量超预算 [已清除]标记 (保数字,不丢信息) (信息真丢)
→大的先落盘 保留最近4条
──────────── ──────────── ───────────────── ────────────
进消息前先做 每turn前跑一次 过阈值才跑,失败 压缩失败/
(便宜、无损) (便宜、无 LLM) 3次就不再试 overflow 兜底

2.1 部件一句话职责

部件干什么在哪个文件
Scratchpadappend-only JSONL,所有工具结果的单一事实源;容错读、软限流、compaction 状态机src/agent/scratchpad.ts
微压缩 microcompact每 turn 前把"旧的、可清除的"工具消息内容换成占位符,保留最近 N 条src/agent/microcompact.ts
全量压缩 compact用 fastModel 把所有工具结果 LLM 摘要成 9 段结构化 summarysrc/agent/compact.ts
硬截断 + 编排truncateMessages 删最旧轮次;manageContextThreshold 三步 fallbacksrc/agent/agent.ts
阈值计算由模型窗口算出"何时该压缩"src/utils/tokens.ts
单结果落盘单个超大结果落盘 + 注入预览src/utils/tool-result-storage.ts
每-turn 预算一个 turn 内工具结果总量封顶src/utils/tool-result-budget.ts

2.2 一个 turn 里这些防线什么时候上

它们不是同时跑,而是嵌在主循环的固定位置,顺序很关键(源码 agent.ts:149-276while 循环):

一次迭代(iteration)内:
1. 微压缩 messages = microcompactMessages(messages) ← 循环开头,turn 级
2. 剥旧思考 stripOldThinking(messages, 2)
3. 调 LLM ─────────(若 overflow → truncateMessages 兜底重试)
4. 执行工具 → 拿回 toolMessages
5. 单结果治理 exceedsSizeCap 逐条落盘 → enforceResultBudget 总量封顶
6. 阈值管理 manageContextThreshold(memory_flush → compaction → truncate)
7. 注入软限流警告 / 排空用户追问队列

记住这条时间线:微压缩在最前(便宜、每 turn 都判),全量压缩在工具跑完后(贵、过阈值才判), 硬截断在两处兜底(overflow 重试 + 压缩失败)。下面逐层拆开。


3. 第一层:Scratchpad —— append-only 的单一事实源

3.1 它要解决的小问题

agent 一次查询里会打很多工具。这些结果需要:能被可靠地追加、能被重新读出来喂给模型、 能在崩溃/坏行时不整体挂掉、还要能"临时隐藏一部分"来腾窗口。Dexter 用一个 append-only 的 JSONL 文件当这一切的底座。

3.2 思路:只追加,不改写

JSONL(每行一个 JSON 对象)天生适合"只往后加":

  • 追加安全——append() 就是 appendFileSync(文件, JSON.stringify(entry) + '\n') (scratchpad.ts:452),永不回改已写内容,进程随时挂掉最多丢最后半行。
  • 单一事实源——所有 tool_result / thinking / init 都进同一个文件,一次查询一个文件 (文件名 = 时间戳 + query 的 md5 前 12 位,scratchpad.ts:91-97)。

关键设计:压缩/清除从不动这个文件。类里明确注释"JSONL file untouched"——清除和压缩都是 内存态,原文永远在盘上。这就是 2.3 节"磁盘不丢"的落地。

3.3 写与读:parse 时容错跳过坏行

写用 addToolResult(toolName, args, result)(scratchpad.ts:108),它会先 parseResultSafely 试着把结果 JSON 解析成对象存(存不动就原样存字符串,scratchpad.ts:289)。

读的一侧最能体现工程稳健性。readEntries()(scratchpad.ts:475)把整个文件按行读, 每一行单独 parse,坏行直接跳过、绝不让一行崩溃整个读取:

// 真实源码 scratchpad.ts:475 readEntries —— 逐行容错
readFileSync(this.filepath, 'utf-8')
.split('\n')
.filter((line) => line.trim())
.map((line) => this.parseLine(line)) // parseLine 内 try/catch,坏行返回 null
.filter((entry): entry is ScratchpadEntry => entry !== null); // 滤掉 null

parseLine(scratchpad.ts:459)不仅 catch JSON 异常,还校验对象必须含 typetimestamp 字段,不合格返回 null重点看:partial write(半行)、磁盘损坏都只损失那一行, getToolResults 这类方法照常工作。

3.4 软工具限流:不封杀,只警告

长研究的一个典型病是重试循环——同一个工具用几乎一样的参数反复打,既费钱又撑上下文。 Dexter 的对策不是硬封,而是软限流:永远允许调用,但把"你已经打了几次 / 这次和上次很像" 作为警告注入 prompt,让模型自己收手。

判定流程(调工具前 canCallTool,scratchpad.ts:131;调成功后 recordToolCall,scratchpad.ts:180):

情形canCallTool 的反应阈值/来源
次数已达上限allowed:true + 警告"已调 N 次,考虑换工具/换词/带着现有数据收尾"默认 maxCallsPerTool: 3(scratchpad.ts:50)
本次 query 与历史很像allowed:true + 警告"和上次很像,还剩 X 次"Jaccard ≥ similarityThreshold: 0.7
还差 1 次到上限allowed:true + 警告"接近上限"currentCount === maxCalls - 1

注意 isBlocked 恒为 false(scratchpad.ts:213)——注释直说 "Never block, just warn"。 这是刻意的:硬封会让 agent 卡死在"没数据也不让再试"的死角,软警告把决策权还给模型。

相似度怎么算(Jaccard): findSimilarQuery(scratchpad.ts:245)先 tokenize (小写、去标点、切词、丢掉长度 ≤2 的词,scratchpad.ts:263),再 calculateSimilarity (scratchpad.ts:276)算两个词集合的 交集/并集:

# 示意,非源码:Jaccard 相似度 —— 两句话的"用词重合度"
def similarity(words_a, words_b):
inter = len(words_a & words_b) # 共同出现的词数
union = len(words_a | words_b) # 总共出现的不同词数
return inter / union # 0~1,越大越像
# "nvidia q4 revenue" vs "nvidia q4 revenues" → 交集大 → 命中 0.7 阈值 → 发警告

最后 formatToolUsageForPrompt(scratchpad.ts:223)把每个工具的 N/max calls 状态汇成一小段 Markdown,主循环在每轮末尾把它 push 进 messages (agent.ts:265-268),模型下一步就"看得见自己打了多少次"。

3.5 Compaction 状态机:三个内存字段协同

scratchpad 还兼任压缩的状态载体。三个内存字段(JSONL 文件依旧不动):

字段作用相关方法
clearedToolIndices: Set<number>记哪些工具结果被"从上下文清除",读出时换成占位符getToolResults 标准模式
compactionSummary: string | null一旦设了,读出时返回摘要而非原始结果setCompactionSummary(scratchpad.ts:393)
compactionBoundaryIndex: number摘要覆盖到第几个结果为止,之后的算"压缩后新数据"getToolResults 压缩模式

getToolResults()(scratchpad.ts:313)是关键读取口,它有两套模式:

  • 压缩模式(有 summary 时):返回 摘要 + 分隔线 + "压缩后新取的数据"。边界前的结果被摘要 覆盖、不再逐条展开;边界后新打的工具结果照常拼进去(scratchpad.ts:317-344)。
  • 标准模式:逐条展开工具结果,clearedToolIndices 里的换成 [Tool result #N cleared from context] 占位符(scratchpad.ts:352-356)。

setCompactionSummary(scratchpad.ts:393)做的事:存下摘要,把 compactionBoundaryIndex 设成"当前工具结果总数 - 1"(即到此为止全被摘要覆盖), 并 clearedToolIndices.clear()——旧的清除状态被摘要取代。一句话:摘要一上, 之前所有零碎清除作废,统一由摘要接管。


4. 第二层:三级压缩梯度(本章主菜)

Dexter 不用一种压缩打天下,而是三个力度递增的挡位,按"上下文有多满 / 出没出错"选。 先用一张表建立全局观,再逐个拆。

挡位手段用不用 LLM信息损失何时触发
微压缩 microcompact旧的可清除工具消息内容 → 占位符否(纯字符串替换)轻(原文仍在 scratchpad)每 turn 开头,超 count/token 阈值
全量压缩 compactfastModel 把所有结果摘成 9 段结构是(一次 LLM 调用)低(数字被刻意保住)过 auto-compact 阈值
硬截断 truncate直接删最旧的对话轮次高(真丢)压缩失败 / overflow 兜底

4.1 微压缩:每 turn 一次的便宜清理

它要解决的小问题: 别等窗口快满了才做那次昂贵的 LLM 压缩;平时就把"旧的、明显能扔"的 工具结果轻轻清掉,把上下文增长压在萌芽里。

思路: 只针对只读类工具(重算无副作用),把老结果的正文换成一句占位符 MC_CLEARED_MESSAGE = '[Old tool result content cleared]'(microcompact.ts:14),保留最近几条。 不叫 LLM,纯数组变换,几乎零成本。

哪些工具可清除,写死在 COMPACTABLE_TOOLS(microcompact.ts:26):get_financialsget_market_dataread_filingsweb_fetchweb_searchread_filememory_search 等 ——全是"再打一次也能拿回来"的读类工具。写类/有副作用的工具不在此列,不会被清。

两个触发阈值(microcompactMessages,microcompact.ts:51):

  • 数量触发:可清除的工具消息 > 8 条(COUNT_TRIGGER_THRESHOLD),清到只剩最近 4 条 (COUNT_KEEP_RECENT)。
  • token 触发:即使条数不多,若这些结果估算总量 > 80,000 token(TOKEN_TRIGGER_THRESHOLD) 也触发——专治"少而巨"的单个大结果。
// 真实实现 microcompact.ts:84-106 —— 保留最近 N,其余换占位符
const keepSet = new Set(compactableIndices.slice(-COUNT_KEEP_RECENT)); // 最近 4 条留着
const clearIndices = compactableIndices.filter(i => !keepSet.has(i)); // 其余清掉
// map 出新数组:被清的那条重建成一个只含 MC_CLEARED_MESSAGE 的 ToolMessage
// (保留 tool_call_id 和 name,保持消息配对不破)

关键细节: token 估算用 Math.ceil(text.length / 3.5)(microcompact.ts:75),和 estimateTokens(tokens.ts:18)同一套"3.5 字符/token"的粗算,全项目口径统一。函数无改动就返回原数组, 有改动才返回新数组并报告 cleared/estimatedTokensSaved/trigger,主循环据此发 microcompact 事件 (agent.ts:153-157)。

4.2 全量压缩:LLM 把结果摘成 9 段结构

它要解决的小问题: 微压缩只是"把旧结果打码",信息虽在 scratchpad 但没进模型视野; 当窗口真的快满、需要在保住信息的前提下大幅瘦身时,就得让一个 LLM 把所有结果读懂再压

思路: 与其硬删(永久丢信息),不如叫一个便宜快的 fastModel 把 scratchpad 里全部工具结果 摘要成结构化文本,用摘要替换原始结果。核心函数 compactContext(compact.ts:194):

// 真实实现 compact.ts:194-227 compactContext —— 用 fastModel 摘要
const provider = resolveProvider(model);
const fastModel = provider.fastModel ?? model; // 便宜模型压缩,省钱
const prompt = buildCompactionPrompt(query, toolResults);
const result = await callLlm(prompt, { model: fastModel, systemPrompt, signal });
// 空响应算失败,抛错 → 由 manageContextThreshold 兜底(见 §5)
const summary = buildCompactSummaryMessage(rawSummary);

摘要的 9 段结构(BASE_COMPACT_PROMPT,compact.ts:45-102): prompt 强制模型产出九个小节—— ①原始查询意图 ②关键概念 ③各工具取到的数据 ④错误与重试 ⑤分析进度 ⑥所有数字 ⑦待补数据 ⑧当前工作状态 ⑨下一步建议。其中第 6 段"Numerical Data"被特别强调 "This section is critical; do not omit any numbers"——这正是金融研究"压缩不能丢数字"的硬约束落进 prompt。

no-tools 铁律(NO_TOOLS_PREAMBLE,compact.ts:28): 压缩这一步只要文本、绝不能调工具。 prompt 头尾各钉一遍:开头 NO_TOOLS_PREAMBLE 警告"调工具会被拒、浪费你唯一的一回合、任务失败", 结尾 NO_TOOLS_TRAILER(compact.ts:104)再钉一次。调用侧也确实不绑工具。这层冗余是防止 fastModel 一时手滑去 call tool,把这次纯摘要给毁了。

摘要后处理(formatCompactSummary,compact.ts:129): 模型被要求先写 <analysis> 草稿再写 <summary><analysis> 是"想清楚"用的脚手架,写完就没价值——用正则 replace(/<analysis>[\s\S]*?<\/analysis>/, '') 剥掉,只留 <summary> 内容并加个 "Summary:" 前缀。

续接(buildCompactSummaryMessage,compact.ts:154): 把摘要包成一句"本次会话从一个 用完上下文的旧会话继续……直接接着做、别复述、别跟用户确认",让模型无缝续研究。压缩产生的这条 summary 既替换 messages(compactMessages,agent.ts:545——只留 System + query+summary), 又调 scratchpad.setCompactionSummary 更新事实源。

4.3 硬截断:兜底才用,信息真丢

它要解决的小问题: 微压缩不够、全量压缩也失败(或不满足条件)时,还得有个"无论如何先让请求发出去"的最后手段。

思路: 直接删掉最旧的对话轮次,只保留最近几轮。这是唯一会真正丢信息的挡位,所以只当兜底。

两个相关函数:

  • stripOldThinking(messages, 2)(agent.ts:482):每 turn 都跑,把旧 AIMessage文本推理 清空,但保留 tool_calls 结构(否则与 ToolMessage 配对会断)。只保留最近 2 条的思考文本。 这算"半截断"——扔的是模型自己的碎碎念,不是数据。
  • truncateMessages(messages, keepRounds)(agent.ts:508):把消息切成"轮次"(一个 AIMessage 带其后的 ToolMessage/HumanMessage),删掉最旧的、只保 keepRounds 轮, 返回删了多少条。System / 历史 / 首个 Human 不动。

两个使用点用不同的 keep 值:overflow 重试时 OVERFLOW_KEEP_ROUNDS = 3(agent.ts:26), 压缩失败兜底时 KEEP_TOOL_USES = 5(tokens.ts:66)。


5. 触发与编排:什么时候上哪一层

前面讲的是"每层怎么做";这节讲"谁在什么条件下决定上哪层"。核心是两处:阈值怎么算、 manageContextThreshold 的三步 fallback。

5.1 阈值:窗口 - 输出预留 - buffer

getAutoCompactThreshold(model)(tokens.ts:50)算出"上下文到多少 token 就该压缩"。 公式是层层扣减:

contextWindow 模型总窗口(provider 给,缺省 128K)
- MAX_OUTPUT_TOKENS_FOR_SUMMARY 减 20K,给压缩那次 LLM 的输出留地方
────────────────────────────
= effectiveContextWindow 有效可用窗口 (getEffectiveContextWindow, tokens.ts:39)
- AUTOCOMPACT_BUFFER_TOKENS 再减 13K 安全 buffer
────────────────────────────
= autoCompactThreshold 超过它 → 触发上下文管理

常量在 tokens.ts:27-33:AUTOCOMPACT_BUFFER_TOKENS = 13_000MAX_OUTPUT_TOKENS_FOR_SUMMARY = 20_000DEFAULT_CONTEXT_WINDOW = 128_000。 两层扣减的用意:留 20K 保证"压缩这步本身"有输出空间,留 13K 保证"判定到真撑爆之间"有缓冲。

"当前用了多少 token"优先取上一次 API 真实回报的 inputTokens(ctx.lastApiInputTokens, agent.ts:562),没有才退回 estimateTokens 字符估算——能用真实值就不猜

5.2 manageContextThreshold:三步 fallback

manageContextThreshold(agent.ts:556)是编排大脑,在每 turn 工具跑完后调用。 若 估算 token ≤ 阈值 直接返回(agent.ts:569),什么都不做;超了才依次尝试三步,每步失败落到下一步:

怎么读: 从上到下是尝试顺序,任一步成功(或不适用)就往下走或结束。

估算 token > 阈值?
│ 否 → return(啥也不做)
│ 是

① 记忆 flush memoryEnabled 且 shouldRunMemoryFlush?
│ 把值得长期记的写进持久记忆(细节见 04-memory.md),alreadyFlushed 防重复

② 全量压缩 compactionFailures < 3 且 活跃结果数 ≥ 3?
│ 成功 → compactMessages + setCompactionSummary,failures 归零,return
│ 失败 → compactionFailures++,发 success:false 事件,落到 ③

③ 硬截断 truncateMessages(messages, KEEP_TOOL_USES=5)
删最旧轮次,发 context_cleared 事件

两个熔断阈值值得记:

  • MAX_CONSECUTIVE_COMPACTION_FAILURES = 3(compact.ts:19):压缩连续失败 3 次就不再试, 直接走硬截断,免得每 turn 都白烧一次 fastModel 调用。成功一次即归零(agent.ts:622)。
  • MIN_TOOL_RESULTS_FOR_COMPACTION = 3(compact.ts:22):活跃结果少于 3 条不值得压缩 ——那点量直接清除/截断就够,叫 LLM 是浪费。

5.3 overflow 重试:API 真报错时的独立兜底

上面是"主动预防";还有一条"事后补救"路径。若 LLM 调用真的抛了 context overflow 错误 (isContextOverflowError,errors.ts:155,会先排除 TPM 限流误判),主循环 (agent.ts:176-182)当场 truncateMessages(messages, OVERFLOW_KEEP_ROUNDS=3),删旧轮次后 continue 重发,最多重试 MAX_OVERFLOW_RETRIES = 2 次。删成功(removed > 0)才重试, 删不动就当真错误上报。这是阈值预判万一没拦住时的最后一道网。


6. 单结果治理:一条结果 / 一个 turn 的总量封顶

前面几层管的是"整个上下文历史";这一层管单次工具输出:一条结果本身太大、或一个 turn 里 并行工具加起来太大,先在它们进入消息数组之前就地治理。位置在主循环第 5 步(agent.ts:227-241)。

6.1 单条超大:落盘 + 留预览

它要解决的小问题: 一次 read_filings 可能回来 200KB 财报原文,单条就能吃掉半个窗口。

思路(tool-result-storage.ts): 单条超过 MAX_TOOL_RESULT_CHARS = 50_000 字符 (exceedsSizeCap,tool-result-storage.ts:55)就把完整原文落盘(persistLargeResult, tool-result-storage.ts:23),消息里只留前 PREVIEW_CHARS = 2_000 字符预览 + 文件路径, 并告诉模型"要看全文用 read_file"(buildPersistedContent,tool-result-storage.ts:43)。

这又是"内存 vs 磁盘"那套:大结果进磁盘,窗口里只放它的"缩略图 + 取回地址",需要时按需拉全文。

6.2 一个 turn 总量:大的先落盘

它要解决的小问题: 单条都没超 50K,但一个 turn 里并行打了六个工具,加起来照样撑爆。

思路(enforceResultBudget,tool-result-budget.ts:21): 一个 turn 内所有工具结果 总字符 > MAX_TURN_RESULT_CHARS = 200_000 就触发,按大小从大到小排序,挨个把最大的落盘, 直到总量落回预算下:

// 真实实现 tool-result-budget.ts:39-48 —— 最大的先落盘,直到降到预算内
const bySize = [...indexed].sort((a, b) => b.content.length - a.content.length);
let remaining = totalChars;
for (const entry of bySize) {
if (remaining <= MAX_TURN_RESULT_CHARS) break; // 够小了就停
toPersist.add(entry.index);
remaining -= entry.content.length;
remaining += 2_500; // 落盘后回补 ~2.5K 预览体积
}

关键细节: 落盘不是白落——每条会换回约 2.5K 的预览占位,所以 remaining 减掉原文长度后要 加回 2,500,估算才准。先砍大的,用最少的落盘次数把总量拉回来。落盘复用 persistLargeResult, 和 §6.1 同一套磁盘机制。


7. 巧妙之处(可借鉴的技术)

  • 单一事实源 + 内存态压缩分离。 原文永远在 append-only JSONL 上,清除/压缩全在内存字段 (clearedToolIndices / compactionSummary),"文件 untouched"。压缩失败、状态混乱都能从盘上原文重来。 (scratchpad.ts:313-406)
  • 压缩梯度按成本排序。 便宜的(纯字符串替换的微压缩)每 turn 都做、先做;贵的(LLM 摘要)过阈值才做; 会丢信息的(硬截断)只兜底。力度和代价严格匹配。(agent.ts:153 / agent.ts:600 / agent.ts:653)
  • "保数字"写进 prompt 而非寄望模型自觉。 压缩 prompt 把 Numerical Data 单列一段并标 "critical, do not omit"——把领域约束硬编码进摘要结构。(compact.ts:56)
  • no-tools 铁律头尾双钉 + 调用侧不绑工具。 三重冗余防 fastModel 在摘要步误调工具。 (compact.ts:28 / compact.ts:104)
  • 软限流而非硬封。 工具次数/相似度只发警告不阻断(isBlocked 恒 false),把"要不要收手"的判断 还给模型,避免卡死。(scratchpad.ts:131-213)
  • 落盘保留可寻回性。 大结果不是删,而是落盘 + 预览 + read_file 地址,模型需要时能拉回全文。 (tool-result-storage.ts:43)
  • 坏行不崩全局。 JSONL 逐行 parse、坏行返回 null 过滤掉,partial write / 磁盘损坏最多丢一行。 (scratchpad.ts:459-485)
  • 优先真实 token,回退估算。 阈值判定优先用 API 回报的 inputTokens,没有才字符估算。(agent.ts:562)

8. 边界与局限(诚实)

  • 硬截断真的丢信息。 truncateMessages 删的旧轮次里,若某数字既没进摘要也不在最近轮次,就永久丢出上下文 (原文仍在 scratchpad,但模型看不到、也不会主动去翻)。硬截断是"保命"不是"保真"。
  • token 估算是粗算。 "3.5 字符/token"(tokens.ts:18microcompact.ts:75)对代码/JSON 尚可, 对多语言/特殊 token 会偏。缺 API 真实值时判定可能偏早或偏晚。
  • 微压缩白名单是硬编码的。 COMPACTABLE_TOOLS(microcompact.ts:26)是写死的集合,新增只读工具若忘了 加进去,其结果就不会被微压缩清理。
  • 相似度是词袋 Jaccard。 calculateSimilarity(scratchpad.ts:276)只看词重合,不懂语义;换成同义词的 重复查询检测不到,措辞略变的相同意图可能误判为"像"。
  • 压缩把消息数组压成两条。 compactMessages(agent.ts:545)成功后 messages 只剩 System + 一条 query+summary,中间对话结构被抹平;摘要没覆盖到的细微上下文就没了。
  • 每-turn 预算的预览回补是常数近似。 enforceResultBudget 用固定 +2,500 估预览体积 (tool-result-budget.ts:47),与真实 PREVIEW_CHARS = 2_000 生成内容略有出入,极端情况下可能多落/少落一条。

9. 代码地图(导航索引)

按符号名 grep 比按行号更抗漂移;下表列真实符号,供人和 agent 直接跳源码。

主题文件路径符号 / 常量
append-only 写入src/agent/scratchpad.tsScratchpad.appendaddToolResult
容错读 JSONLsrc/agent/scratchpad.tsreadEntriesparseLine
软工具限流src/agent/scratchpad.tscanCallToolrecordToolCallformatToolUsageForPrompt
Jaccard 相似度src/agent/scratchpad.tsfindSimilarQuerycalculateSimilaritytokenize
压缩状态机src/agent/scratchpad.tsgetToolResultssetCompactionSummaryclearedToolIndicescompactionBoundaryIndex
微压缩src/agent/microcompact.tsmicrocompactMessagesMC_CLEARED_MESSAGECOUNT_TRIGGER_THRESHOLDTOKEN_TRIGGER_THRESHOLDCOMPACTABLE_TOOLS
全量压缩src/agent/compact.tscompactContextbuildCompactionPromptformatCompactSummarybuildCompactSummaryMessageNO_TOOLS_PREAMBLE
压缩熔断阈值src/agent/compact.tsMAX_CONSECUTIVE_COMPACTION_FAILURESMIN_TOOL_RESULTS_FOR_COMPACTION
阈值计算src/utils/tokens.tsgetAutoCompactThresholdgetEffectiveContextWindowestimateTokensAUTOCOMPACT_BUFFER_TOKENS
硬截断 / 剥思考src/agent/agent.tstruncateMessagesstripOldThinkingcompactMessages
三步编排src/agent/agent.tsmanageContextThreshold
overflow 兜底src/agent/agent.ts / src/utils/errors.tsMAX_OVERFLOW_RETRIESOVERFLOW_KEEP_ROUNDSisContextOverflowError
单结果落盘src/utils/tool-result-storage.tsexceedsSizeCappersistLargeResultbuildPersistedContentMAX_TOOL_RESULT_CHARS
每-turn 预算src/utils/tool-result-budget.tsenforceResultBudgetMAX_TURN_RESULT_CHARS

相邻章节: 记忆 flush 落库细节 → 04-memory.md;主循环骨架 → 01-agent-loop.md; 工具路由与权限 → 02-tools-and-permissions.md;全景 → index.md