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_source=taotoken_aicg_blog_end&utm_content=recall_intro 拿一个 Key,把请求地址统一设成https://taotoken.net/api,然后走同一条链路做 Token 记账,是这次排查能落地的关键。下面把整个过程拆成可跟做的几步:先定义 Token 归属,再改工具供应商,最后用一张消耗表把召回、重排、答案生成三段的账算清楚。
2. 检索链路三段账:召回、重排、答案生成的 Token 归属拆分
很多人做检索 Token 统计时只统计一次"检索调用",结果永远对不上号。真实链路至少有三段,各自的输入构成完全不同:
第一段是召回(recall)。这里的 Token 消耗通常发生在两个地方:一是把用户当前 query 或 Agent 的检索意图做 embedding / query rewrite;二是如果用的是"多查询扩展"(multi-query)策略,一次检索会变成 3~5 次并行查询调用。这段的特点是输入短、调用次数多,容易被忽略但总量不可小看。
第二段是重排(rerank)。重排模型的输入是 query + 候选文档对,候选数通常是top_k的 3~5 倍。如果召回 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 三种接入姿势
在开始记账之前,得先让所有调用都走同一条出口,否则统计口径不统一。统一的方式就是先把供应商切到 TaoToken:Key 在官网控制台创建,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_source=taotoken_aicg_blog_end&utm_content=recall_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_KEY="YOUR_API_KEY"CC 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_source=taotoken_aicg_blog_end&utm_content=recall_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 可能接近 700~900 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(path="recall_token_report.csv"): with open(path, "w", newline="", encoding="utf-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_k=12, candidate_k=48) run_stage("rerank", reranker.rerank, query, candidates) run_stage("answer", llm.chat, messages) dump()跑完一轮之后,我得到的是下面这种形态的消耗表(下面数值来自我本地一次压测,仅用于说明量级和方法,不代表任何固定套餐或承诺):
| 阶段 | 调用次数 | 输入 Token | 输出 Token | 平均单次输入 | 占总输入比 |
|---|---|---|---|---|---|
| 召回(含 multi-query) | 6 | 1,850 | 0 | 308 | 2.3% |
| 重排 | 1 | 21,600 | 120 | 21,600 | 26.8% |
| 答案生成 | 1 | 57,200 | 2,400 | 57,200 | 70.9% |
| 合计 | 8 | 80,650 | 2,520 | — | 100% |
这张表一出来,结论就很清楚了:重排虽然只调用一次,但吃掉了四分之一的输入;答案生成是大头,而它的输入里有相当一部分来自召回注入。真正需要优化的不是"检索调用次数",而是"注入到答案生成里的那一坨记忆"。
再往前追一层,把答案生成的输入按来源拆开,会得到更有用的视图:
| 来源 | Token 估算 | 可压缩性 | 处理建议 |
|---|---|---|---|
| 召回 chunk(12 条) | 6,100 | 高 | 压 chunk 上限、去重、按分数截断 |
| 历史压缩摘要 | 9,800 | 中 | 定期重压缩,控制摘要层级 |
| todo-state 复述 | 1,200 | 低 | 保留,这是防目标丢失的关键 |
| 系统提示与工具描述 | 4,300 | 低 | 精简工具 schema |
| 当前对话历史 | 35,800 | 高 | 卸载到外部存储,按需回捞 |
注意最后一行:真正让上下文爆掉的往往不是记忆召回,而是对话历史本身。检索系统工程师的价值就在这里——把"哪部分历史应该被卸载、哪部分应该被召回"这件事,变成一个有预算约束的检索问题,而不是无脑全塞。官网 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content=recall_metrics 的控制台里可以按 Key 查看调用量,配合本地打点做交叉验证,能把口径对得更准。
6. 跨会话长期记忆的工程化:写入、去重、TTL 与 todo-state 复述
跨会话长期记忆之所以难做,是因为它把"写入"和"读取"解耦了。写入时不知道未来会被谁召回,读取时不知道当时写入了什么。这中间如果没有治理,记忆库会快速膨胀,每次召回的候选集里噪音越来越多,重排成本随之上升。
我通常按四层来管:
第一层是写入闸门。不是所有对话都值得写入长期记忆。我会在写入前做一次轻量筛选:是否包含明确结论、是否包含稳定的偏好或约束、是否是可复用的实体关系。筛选可以用快模型来做,成本很低。
第二层是去重与合并。同一事实被反复写入是必然的,所以要有一个近邻去重环节,阈值一般在 0.9 附近。命中近似重复时,选择"更新旧条目"而不是"新增一条",避免召回时同一事实占多个名额。
第三层是 TTL 与衰减。给每条记忆打一个last_used_at和hit_count,长期没被召回且未被引用的条目降权或归档。这一步能显著降低重排候选集的平均噪音。
第四层是 todo-state 复述与记忆的配合。长任务里防目标丢失靠的是 todo-state 复述,而跨会话恢复靠的是记忆召回。两者分工要清楚:todo-state 放"当前在做什么、下一步是什么",记忆召回放"过去确认过什么约束和事实"。如果混在一起,摘要会变得又长又模糊,答案生成的输入会迅速膨胀。我的做法是把 todo-state 固定在一个很小的 token 预算里(比如 800~1200),强制它保持精炼;把可变的、体量大的历史放到记忆库,按需召回。
这四层做好之后,检索链路的 Token 消耗会呈现一个更健康的分布:召回阶段调用次数可能变多(因为要做多次细粒度检索),但单次输入变短;重排的候选数下降;答案生成的注入量稳定在一个预算区间内。
再补一个实操细节:检索意图改写这一步,建议用快模型执行,并且把改写结果缓存起来。同一个会话里连续几轮检索的意图往往高度相似,缓存命中之后能直接省掉这一路调用。缓存 key 可以用"规范化 query + 会话 ID 前缀 + top_k 配置指纹",避免配置变了还命中旧缓存。
7. 收尾:把检索预算当成一等公民
回到标题那个问题——长期记忆召回场景下,谁在消耗检索 Token?答案是三段共同消耗,但权重完全不对称:召回负责"广度",调用多但单次轻;重排负责"精度",一次调用吃掉大块输入;答案生成负责"呈现",输入最大且最容易失控。真正需要被约束的不是召回条数,而是"注入到主上下文里的记忆总量"。
可复现的产出就两样:一份带预算闸门的召回参数配置,和一张按阶段拆分的 Token 消耗表。前者把不确定性关进笼子,后者让优化有据可依。两者都建立在同一个前提上——所有调用走同一条出口,口径才统一。
如果你正准备做这一步,建议按下面的路径走一遍:先开一个 Key 把出口统一切到https://taotoken.net/api,再用最小调用验证配置生效。可以直接从模型对话页 https://taotoken.net/models/detail/chat?utm_source=taotoken_aicg_blog_end&utm_content=recall_cta_chat 发起一次测试请求,确认链路通;长任务和 Agent 场景可以看 Coding Plan 的说明 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=recall_cta_plan ,按实际调用量选择;然后在控制台创建独立的 Key https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=recall_cta_key ,给检索链路单独一个,方便分账;Claude Code 用户直接对照文档 https://taotoken.net/doc/ClaudeCodeAnthropic?utm_source=taotoken_aicg_blog_end&utm_content=recall_cta_doc 把settings.json改完就能跑。
最后提醒一句:上面表格里的数值来自我本地一次具体压测,你的 chunk 切法、rerank 实现、模型选择都会让结果不同。方法可以照搬,数字必须自己量。把账记清楚,比把top_k调大调小重要得多。