
1. 从一条 recall_log 说起3.4 万 token 的记忆注入是怎么发生的上周排查一个跑了两个多小时的长任务 Agent它在第 17 轮规划时突然抛context_length_exceeded但当时的对话历史只有 2.6 万 token离模型上限还差得远。把 trace 摊开看问题出在检索侧那一轮的记忆召回返回了 38 条 chunk平均每条 880 token光被注入的长期记忆就占了 3.4 万 token紧接着的 rerank 调用本身又带上了一份候选列表最后答案生成时还要把 chunk、历史摘要、todo-state 一起塞进 prompt。三段加起来单轮检索链路的输入量接近 8 万 token。这类现象在 Agent 长任务上下文工程里非常典型。预算控制、压缩、todo-state 复述、跨会话记忆召回这四类机制本质上都在抢同一份上下文预算而长期记忆召回是最容易被忽视、也最容易失控的那一类——因为它发生在看不见的地方很多框架只暴露一个top_k剩下的 chunk 长度、去重策略、重排候选数全是默认值。作为检索系统工程师我关心的不是能不能召回到而是召回来的东西值不值它占的那些 Token。先到 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_contentrecall_intro 拿一个 Key把请求地址统一设成https://taotoken.net/api然后走同一条链路做 Token 记账是这次排查能落地的关键。下面把整个过程拆成可跟做的几步先定义 Token 归属再改工具供应商最后用一张消耗表把召回、重排、答案生成三段的账算清楚。2. 检索链路三段账召回、重排、答案生成的 Token 归属拆分很多人做检索 Token 统计时只统计一次检索调用结果永远对不上号。真实链路至少有三段各自的输入构成完全不同第一段是召回recall。这里的 Token 消耗通常发生在两个地方一是把用户当前 query 或 Agent 的检索意图做 embedding / query rewrite二是如果用的是多查询扩展multi-query策略一次检索会变成 35 次并行查询调用。这段的特点是输入短、调用次数多容易被忽略但总量不可小看。第二段是重排rerank。重排模型的输入是 query 候选文档对候选数通常是top_k的 35 倍。如果召回 40 条候选、每条 600 token那么一次 rerank 的输入就是 2.4 万 token 级别。重排是整条链路里 Token 放大倍数最高的环节也是最值得卡候选数的地方。第三段是答案生成answer generation。这里才是最终注入到主上下文的部分经过 rerank 和后处理之后的 chunk、历史压缩摘要、todo-state 复述文本再加上系统提示。它的输出 Token 也不低因为长任务里的答案往往带规划步骤。把这三段分开记账之后谁在消耗检索 Token这个问题才有答案。我通常会用这样一个归属视图先做一轮粗筛阶段典型调用输入规模特征输出规模特征主要放大风险召回embedding / multi-query短输入 × 多次调用向量无文本输出并行查询数量重排rerank长输入query × 候选数分数列表候选条数 × chunk 长度答案生成chat completion超长输入chunk 摘要 todo中长文本输出注入 chunk 总长度注意这张表只描述结构实际数值必须自己在压测里量。不同 embedding 模型、不同 chunk 切法、不同 rerank 实现结果差异能到几倍。3. 先拿 Key 再改地址Claude Code、Codex、CC Switch 三种接入姿势在开始记账之前得先让所有调用都走同一条出口否则统计口径不统一。统一的方式就是先把供应商切到 TaoTokenKey 在官网控制台创建Base URL 统一写成https://taotoken.net/api。下面是三种常见工具的配置方式注意它们的环境变量体系不一样不要互相套用。Claude Code走settings.json核心是ANTHROPIC_*系列变量{ env: { ANTHROPIC_BASE_URL: https://taotoken.net/api, ANTHROPIC_AUTH_TOKEN: YOUR_API_KEY, ANTHROPIC_MODEL: claude-sonnet-4-5, ANTHROPIC_SMALL_FAST_MODEL: claude-haiku-4-5, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: 1 } }ANTHROPIC_MODEL和ANTHROPIC_SMALL_FAST_MODEL的具体模型 ID 以官网模型列表为准长任务场景建议主模型和快模型分开把检索意图改写这类轻量调用交给快模型。完整的 Claude Code 接入说明可以对照 https://taotoken.net/doc/ClaudeCodeAnthropic?utm_sourcetaotoken_aicg_blog_endutm_contentrecall_cc_doc 逐项核对。Codex走config.toml注意它用的是model_provider体系跟ANTHROPIC_*完全无关model gpt-5-codex model_provider taotoken [model_providers.taotoken] name TaoToken base_url https://taotoken.net/api env_key TAOTOKEN_API_KEY wire_api responses对应的环境变量单独设置export TAOTOKEN_API_KEYYOUR_API_KEYCC Switch 三件套指的是供应商地址、密钥、模型名这三项。切换时只需要替换这三个值不要动工具自身的其他参数provider: name: taotoken base_url: https://taotoken.net/api api_key: YOUR_API_KEY model: main: claude-sonnet-4-5 fast: claude-haiku-4-5三种方式配完之后建议先用一次最小调用验证出口是否生效再开始跑检索链路压测。官网首页 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_contentrecall_setup 上有创建 Key 的入口控制台里也能看到每个 Key 的调用情况这些数据是后面记账的基准。4. 召回参数配置模板top_k、chunk 上限、相似度阈值与预算闸门回到检索本身。我在排查时最常看到的配置是只有一个top_k其他全默认。实际上影响检索 Token 的参数至少有六个它们互相牵制recall: strategy: hybrid top_k: 12 # 最终注入答案生成的条数 candidate_k: 48 # 进入重排的候选条数 max_chunk_tokens: 512 # 单条 chunk 的硬上限超出截断 min_score: 0.32 # 相似度阈值低于此值直接丢弃 dedup: enabled: true similarity: 0.92 # 近似重复的判定阈值 max_per_source: 2 # 同一来源最多保留条数 budget: max_recall_tokens: 6000 # 注入答案生成的记忆预算上限 max_rerank_candidates: 48 # 重排候选硬上限 degrade_on_exceed: true # 超预算时降级而非报错 rerank: enabled: true batch_size: 16 truncate_tokens: 384 # 送进重排的单条截断长度 answer: include_todo_state: true include_summary: true max_prompt_tokens: 24000这份配置里有三个地方是省钱的关键。第一个是candidate_k与top_k的比例。默认 4:1 是常见做法但如果你的 chunk 普遍偏长比例要往下压。重排的输入成本大致正比于candidate_k × 每条 chunk 长度把候选从 48 降到 32重排这一段能省掉三分之一。第二个是max_chunk_tokens的截断。很多检索系统的 chunk 是按字符切的一条 2000 字符的 chunk 可能接近 700900 token。给一个 512 的硬上限配合截断时保留首尾的策略通常比整条注入更划算。第三个是budget.degrade_on_exceed。长任务里最怕的不是召回质量差一点而是直接报错中断。设置一个max_recall_tokens闸门超了就按分数从低到高丢 chunk而不是让整轮规划失败。这个闸门本质上是把上下文预算控制从 harness 层下沉到了检索层。5. 检索 Token 消耗表与压测方法把每一路调用都记账有了配置接下来要能量。我在本地用一段脚本对三段调用分别打点把输入输出 Token 和延迟一起落到 CSV再按阶段聚合。import csv import time from collections import defaultdict BUCKETS defaultdict(lambda: {calls: 0, in: 0, out: 0, ms: 0}) def record(stage, usage, elapsed_ms): b BUCKETS[stage] b[calls] 1 b[in] usage.get(input_tokens, 0) b[out] usage.get(output_tokens, 0) b[ms] int(elapsed_ms) def run_stage(stage, fn, *args, **kwargs): t0 time.perf_counter() result fn(*args, **kwargs) elapsed (time.perf_counter() - t0) * 1000 usage getattr(result, usage, {}) or {} record(stage, usage, elapsed) return result def dump(pathrecall_token_report.csv): with open(path, w, newline, encodingutf-8) as f: w csv.writer(f) w.writerow([stage, calls, input_tokens, output_tokens, latency_ms, avg_input_per_call]) for stage, b in BUCKETS.items(): avg b[in] / b[calls] if b[calls] else 0 w.writerow([stage, b[calls], b[in], b[out], b[ms], round(avg, 1)])调用侧这样接入run_stage(recall, retriever.search, query, top_k12, candidate_k48) run_stage(rerank, reranker.rerank, query, candidates) run_stage(answer, llm.chat, messages) dump()跑完一轮之后我得到的是下面这种形态的消耗表下面数值来自我本地一次压测仅用于说明量级和方法不代表任何固定套餐或承诺阶段调用次数输入 Token输出 Token平均单次输入占总输入比召回含 multi-query61,85003082.3%重排121,60012021,60026.8%答案生成157,2002,40057,20070.9%合计880,6502,520—100%这张表一出来结论就很清楚了重排虽然只调用一次但吃掉了四分之一的输入答案生成是大头而它的输入里有相当一部分来自召回注入。真正需要优化的不是检索调用次数而是注入到答案生成里的那一坨记忆。再往前追一层把答案生成的输入按来源拆开会得到更有用的视图来源Token 估算可压缩性处理建议召回 chunk12 条6,100高压 chunk 上限、去重、按分数截断历史压缩摘要9,800中定期重压缩控制摘要层级todo-state 复述1,200低保留这是防目标丢失的关键系统提示与工具描述4,300低精简工具 schema当前对话历史35,800高卸载到外部存储按需回捞注意最后一行真正让上下文爆掉的往往不是记忆召回而是对话历史本身。检索系统工程师的价值就在这里——把哪部分历史应该被卸载、哪部分应该被召回这件事变成一个有预算约束的检索问题而不是无脑全塞。官网 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_contentrecall_metrics 的控制台里可以按 Key 查看调用量配合本地打点做交叉验证能把口径对得更准。6. 跨会话长期记忆的工程化写入、去重、TTL 与 todo-state 复述跨会话长期记忆之所以难做是因为它把写入和读取解耦了。写入时不知道未来会被谁召回读取时不知道当时写入了什么。这中间如果没有治理记忆库会快速膨胀每次召回的候选集里噪音越来越多重排成本随之上升。我通常按四层来管第一层是写入闸门。不是所有对话都值得写入长期记忆。我会在写入前做一次轻量筛选是否包含明确结论、是否包含稳定的偏好或约束、是否是可复用的实体关系。筛选可以用快模型来做成本很低。第二层是去重与合并。同一事实被反复写入是必然的所以要有一个近邻去重环节阈值一般在 0.9 附近。命中近似重复时选择更新旧条目而不是新增一条避免召回时同一事实占多个名额。第三层是 TTL 与衰减。给每条记忆打一个last_used_at和hit_count长期没被召回且未被引用的条目降权或归档。这一步能显著降低重排候选集的平均噪音。第四层是 todo-state 复述与记忆的配合。长任务里防目标丢失靠的是 todo-state 复述而跨会话恢复靠的是记忆召回。两者分工要清楚todo-state 放当前在做什么、下一步是什么记忆召回放过去确认过什么约束和事实。如果混在一起摘要会变得又长又模糊答案生成的输入会迅速膨胀。我的做法是把 todo-state 固定在一个很小的 token 预算里比如 8001200强制它保持精炼把可变的、体量大的历史放到记忆库按需召回。这四层做好之后检索链路的 Token 消耗会呈现一个更健康的分布召回阶段调用次数可能变多因为要做多次细粒度检索但单次输入变短重排的候选数下降答案生成的注入量稳定在一个预算区间内。再补一个实操细节检索意图改写这一步建议用快模型执行并且把改写结果缓存起来。同一个会话里连续几轮检索的意图往往高度相似缓存命中之后能直接省掉这一路调用。缓存 key 可以用规范化 query 会话 ID 前缀 top_k 配置指纹避免配置变了还命中旧缓存。7. 收尾把检索预算当成一等公民回到标题那个问题——长期记忆召回场景下谁在消耗检索 Token答案是三段共同消耗但权重完全不对称召回负责广度调用多但单次轻重排负责精度一次调用吃掉大块输入答案生成负责呈现输入最大且最容易失控。真正需要被约束的不是召回条数而是注入到主上下文里的记忆总量。可复现的产出就两样一份带预算闸门的召回参数配置和一张按阶段拆分的 Token 消耗表。前者把不确定性关进笼子后者让优化有据可依。两者都建立在同一个前提上——所有调用走同一条出口口径才统一。如果你正准备做这一步建议按下面的路径走一遍先开一个 Key 把出口统一切到https://taotoken.net/api再用最小调用验证配置生效。可以直接从模型对话页 https://taotoken.net/models/detail/chat?utm_sourcetaotoken_aicg_blog_endutm_contentrecall_cta_chat 发起一次测试请求确认链路通长任务和 Agent 场景可以看 Coding Plan 的说明 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentrecall_cta_plan 按实际调用量选择然后在控制台创建独立的 Key https://taotoken.net/console/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentrecall_cta_key 给检索链路单独一个方便分账Claude Code 用户直接对照文档 https://taotoken.net/doc/ClaudeCodeAnthropic?utm_sourcetaotoken_aicg_blog_endutm_contentrecall_cta_doc 把settings.json改完就能跑。最后提醒一句上面表格里的数值来自我本地一次具体压测你的 chunk 切法、rerank 实现、模型选择都会让结果不同。方法可以照搬数字必须自己量。把账记清楚比把top_k调大调小重要得多。