多轮工具调用 Agent 的成本归因,第一步是统一出口:TaoToken(https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content=cost_attribution)把 Key 和 Base URL 收在一处之后,PaperScout 每跑一次 Search / Expand 循环花了多少 token、落在哪个模型上、单位召回率烧掉多少钱,才能被逐条对账。如果你正准备复现"相同工具调用次数下召回率"这组结论,建议先用同一个 Key 跑通链路:去官网拿 Key,Base URL 统一填 https://taotoken.net/api。这篇文章不聊 PSPO 的算法推导,只做一件成本分析工程师该做的事——把多轮检索里那段最不可控的账单,拆成一张能复现、能对比、能追责的归因表。
1. 为什么 PaperScout 的成本必须按"工具调用区间"统计
把学术检索建模成 POMDP 之后,Agent 的每一步动作不再是"发一条 query",而是"在论文池上做一次决策"。落到工程侧,这个决策会展开成一次完整的模型往返:把当前论文池、历史动作、参考文献摘要一起塞进上下文,让模型输出下一个动作是什么、参数是什么,再交给检索后端执行。
这带来三个直接后果:
上下文是累积的。第一轮 Search 的 prompt 可能只有几百 token,到第二十轮 Expand 时,历史论文摘要、已探索标记、去重记录全都堆在 messages 里,prompt 轻松破万。如果按"单次请求平均 token × 请求次数"做预算,误差会非常夸张。
工具调用次数不可预知。同一道题,Agent 可能在第 6 轮就判断引用链饱和并切换分支,也可能在第 30 轮还在原地 Expand。调用次数是策略学出来的,不是配置写死的。
动作类型影响单步成本。Search 要生成检索式、可能触发一次外部检索;Expand 要挑论文、拉参考文献、做相关性判断。两者的 completion 长度分布完全不同,混在一起算均值会掩盖掉真实开销。
所以,衡量一个多轮检索 Agent 是否"划算",最合理的横轴不是时间、也不是 token,而是工具调用次数。论文里那张"相同工具调用次数下取得更高召回率"的曲线,翻译成成本语言就是:在同样的调用预算下,谁的召回更高,谁的单位召回成本更低。
这就给成本分析定义了任务:给定一条 recall@tool_calls 曲线,反推出每一档调用次数对应的累计费用。
2. 先统一网关:把 Key 与 Base URL 定死
做成本归因最怕的不是贵,而是口径不统一。实验组用 A 家的 Key、对照组用 B 家的 Key,单价、缓存策略、限流阈值全不一样,最后算出来的"成本差异"其实是供应商差异。
工程上最省事的做法是:所有 Agent 进程、所有客户端、所有实验分支,走同一个 Base URL,用同一类 Key,只通过 Key 的标签区分实验组。
TaoToken 控制台里创建的 Key 就是干这个的:每个实验分支一个 Key,Base URL 全部指向https://taotoken.net/api,最后账单可以直接按 Key 名聚合。官网入口见 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content=key_per_branch ,创建与轮换在控制台完成。
本文全程使用的两个常量:
Base URL : https://taotoken.net/api API Key : YOUR_API_KEY注意两点:Base URL 是给工具配置用的,不带 UTM 参数,别把营销链接直接填进去;Key 一律走环境变量或配置文件,不要硬编码在仓库里。
3. 三套客户端配置:Claude Code、Codex、CC Switch 各写各的
PaperScout 这类工程通常自己直连 API,但做调优时你大概率会同时开 Claude Code 读代码、开 Codex 改脚本。这三者配置方式完全不同,混用变量名是最常见的踩坑点。
3.1 Claude Code:settings.json 里的 ANTHROPIC_*
Claude Code 读~/.claude/settings.json的env段。所有ANTHROPIC_*变量只属于它,不要把下面这组变量名复制到 Codex 里。
{ "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" } }几个实操要点:
ANTHROPIC_BASE_URL填到根路径即可,不要自己再拼/v1。ANTHROPIC_AUTH_TOKEN就是你在控制台创建的那串 Key。ANTHROPIC_MODEL与ANTHROPIC_SMALL_FAST_MODEL建议分开配:主模型跑检索决策,小模型跑摘要压缩、去重判断这类轻活。多轮 Agent 的成本大头往往不在决策本身,而在每轮都要重做的上下文压缩。- 改完配置重新开终端,避免旧进程还在用上一次的环境变量。
完整对接说明与变量清单在 Claude Code 文档页,见文末。
3.2 Codex:config.toml 里的自定义 provider
Codex 走的是~/.codex/config.toml,用 provider 段声明,字段名和 Claude Code 完全无关。把 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 = "chat"配套的环境变量:
export TAOTOKEN_API_KEY="YOUR_API_KEY"env_key写的是环境变量名而不是 Key 本身,这是最容易填错的一格。wire_api按网关实际支持的协议选,先试chat;如果供应商侧同时暴露了 Responses 接口且你的 Codex 版本要求走它,再切过去,切换前先用一条最小请求验证返回结构。
3.3 CC Switch:三件套一次配齐
如果你在多个供应商之间来回切,手工改配置文件迟早会出错。CC Switch 这类切换器把配置拆成三件套:
- 供应商条目:名称 + Base URL(
https://taotoken.net/api)+ Key(YOUR_API_KEY)。 - 模型映射:把主模型、快速模型、长上下文模型分别映射到具体模型 ID,一张表管住所有客户端的模型选择。
- 切换开关:在"当前生效供应商"上按一下,同时改写 Claude Code 与 Codex 两边的配置文件,并保留上一份配置便于回滚。
三件套的价值不在于省几次粘贴,而在于实验可复现:跑对比实验前先固定当前供应商条目名,实验结束后 Key 与 Base URL 都能原样复现,归因表里的每一行才有意义。
3.4 自研 Agent(PaperScout 这类工程):环境变量优先
PaperScout 是开源工程,你大概率会改它的模型调用层。不论底层用哪家 SDK,都建议改成从环境变量读取:
import os BASE_URL = os.environ.get("TAOTOKEN_BASE_URL", "https://taotoken.net/api") API_KEY = os.environ["TAOTOKEN_API_KEY"]这样切换实验分支时只用改环境变量,代码零改动,也顺手解决了 Key 不进仓库的问题。
4. 归因表的字段设计:工具调用次数 × Key × Base URL
"可复现产出"的核心是一张表。表设计得对不对,决定了你能不能回答"第 12 轮到底亏在哪"。
推荐字段如下:
| 字段 | 含义 | 为什么需要 |
|---|---|---|
ts | 请求时间戳(毫秒) | 对齐并发窗口与限流事件 |
session_id | 一次检索会话 | 同一道题的多次请求归组 |
turn_index | 第几轮决策 | 定位成本突增的轮次 |
action | Search / Expand | 区分动作类型成本分布 |
tool_call_cum | 本会话累计工具调用次数 | 横轴,直接对接召回率曲线 |
key_label | Key 的实验标签 | 多分支对比 |
base_url | 实际请求的 Base URL | 防止某分支偷偷走了别的网关 |
model | 实际生效的模型 ID | 模型映射是否被切换器改错 |
prompt_tokens | 输入 token | 上下文膨胀的主要观察项 |
completion_tokens | 输出 token | 动作生成开销 |
cached_tokens | 命中缓存的输入 token | 决定单位成本能否压下来 |
latency_ms | 端到端耗时 | 与超时重试关联 |
retry_count | 该轮重试次数 | 失败成本经常被漏算 |
两张关键视图:
按会话累计视图:把tool_call_cum当横轴,sum(cost)当纵轴,得到单题的成本曲线。这张图和 recall 曲线并排放,才能看出"多花 10 次调用换来多少召回"。
按 Key 分组视图:按key_label聚合,得到每条实验分支的总成本与单位召回成本。这一步能立刻暴露"对照组其实走了不同单价"这类低级错误。
5. 采集脚本:把每一次工具调用的 usage 落成 CSV
下面这段脚本可以直接套在 PaperScout 的模型调用层外面,作为装饰器或包装函数使用。它做三件事:透传请求、把 usage 落盘、维护tool_call_cum计数。
# cost_ledger.py import csv import os import time from pathlib import Path from openai import OpenAI LEDGER = Path("papertool_ledger.csv") HEADER = [ "ts", "session_id", "turn_index", "action", "tool_call_cum", "key_label", "base_url", "model", "prompt_tokens", "completion_tokens", "cached_tokens", "latency_ms", "retry_count", ] client = OpenAI( api_key=os.environ["TAOTOKEN_API_KEY"], base_url=os.environ.get("TAOTOKEN_BASE_URL", "https://taotoken.net/api"), ) _cum = {} def _ensure_ledger(): if not LEDGER.exists(): with LEDGER.open("w", newline="", encoding="utf-8") as f: csv.writer(f).writerow(HEADER) def call_model(session_id: str, turn_index: int, action: str, messages, tools=None, model="gpt-4.1-mini"): """一次完整的模型往返,返回 (response, usage_row)。""" _ensure_ledger() t0 = time.time() kwargs = {"model": model, "messages": messages} if tools: kwargs["tools"] = tools kwargs["tool_choice"] = "auto" resp = client.chat.completions.create(**kwargs) latency = int((time.time() - t0) * 1000) usage = resp.usage calls = len(resp.choices[0].message.tool_calls or []) _cum[session_id] = _cum.get(session_id, 0) + calls cached = 0 details = getattr(usage, "prompt_tokens_details", None) if details is not None: cached = getattr(details, "cached_tokens", 0) or 0 row = [ int(time.time() * 1000), session_id, turn_index, action, _cum[session_id], os.environ.get("KEY_LABEL", "default"), os.environ.get("TAOTOKEN_BASE_URL", "https://taotoken.net/api"), resp.model, usage.prompt_tokens, usage.completion_tokens, cached, latency, 0, ] with LEDGER.open("a", newline="", encoding="utf-8") as f: csv.writer(f).writerow(row) return resp, row调用侧只需要把原来的直连替换成call_model(...),session_id用题目 ID,action传"Search"或"Expand"。
聚合脚本:
# aggregate.py import csv from collections import defaultdict PRICE_IN = 0.0 # 按你的 Key 实际单价填写(元 / 千 token) PRICE_OUT = 0.0 buckets = defaultdict(lambda: {"calls": 0, "cost": 0.0, "sessions": set()}) with open("papertool_ledger.csv", encoding="utf-8") as f: for r in csv.DictReader(f): cum = int(r["tool_call_cum"]) cost = ( int(r["prompt_tokens"]) / 1000 * PRICE_IN + int(r["completion_tokens"]) / 1000 * PRICE_OUT ) b = buckets[cum // 5 * 5] # 每 5 次调用分一档 b["calls"] += 1 b["cost"] += cost b["sessions"].add(r["session_id"]) print(f"{'调用区间':<10}{'请求数':<8}{'会话数':<8}{'累计成本':<12}") for k in sorted(buckets): b = buckets[k] print(f"{f'{k}-{k+4}':<10}{b['calls']:<8}{len(b['sessions']):<8}{b['cost']:<12.4f}")跑完这一步,你手里就有了"工具调用次数区间 → 累计成本"的对照表,可以直接和召回率曲线拼在同一张图上。
6. 相同调用次数下的对照实验:排障清单
复现"相同工具调用次数、不同召回率"时,最容易出问题的不是模型本身,而是配置和网络层。按下面顺序排查,能省掉大量无效对比。
401 / 403。九成是 Key 没读到。检查TAOTOKEN_API_KEY是否在当前 shell 生效;Claude Code 看ANTHROPIC_AUTH_TOKEN,Codex 看env_key指向的环境变量名是否拼错。注意 Codex 的env_key填的是变量名,不是 Key。
404。多半是 Base URL 被写成了带路径的形式。统一用https://taotoken.net/api,不要自己追加版本号。
429。多轮 Agent 天然高并发,尤其是 Expand 之后跟着一批相关性判断请求。给采集脚本加指数退避,并把retry_count记进表里——重试产生的成本如果不算进去,归因表会偏乐观。
latency 突然翻倍但 token 没涨。通常是上下文里塞了过长的参考文献原文。在 prompt 侧做摘要压缩,比换更贵的模型有效得多。
缓存命中率异常低。多轮 Agent 的 prompt 是"前缀稳定、尾部增长"的结构,天然适合缓存。如果你发现cached_tokens长期接近 0,先检查是不是每轮都把 messages 重排了一遍。保持前缀不变,是压低单位成本最直接的手段。
两条分支成本差异过大但代码相同。去看归因表里的base_url和model两列。切换器改了配置但进程没重启,是这类"幽灵差异"的常见来源。
7. 把归因表接回召回率曲线
论文报告的那组对比里,最值得成本工程师记下来的不是具体数值,而是那根横轴的含义:在较宽的工具调用区间内,经过策略优化的 4B 模型可以逼近未做同类训练的大模型。
从归因表的角度看,这句话等价于:单位召回成本可以被训练策略压低,而不是只能靠换更大的模型。
具体怎么验证:
- 固定
session_id的题目集合,跑两套策略(有 / 无策略优化)。 - 用采集脚本记录每轮的
tool_call_cum与prompt_tokens。 - 按 5 次调用一档分桶,算出每档的累计成本与累计召回。
- 画两条曲线:横轴调用次数,左纵轴召回率,右纵轴累计成本。
- 找交点——在哪个调用区间,两套策略的召回拉开差距,而成本差距还没同步放大。
这个交点,就是"多轮检索 Agent 值不值得继续多想几步"的工程答案。
还有一点容易被忽略:Expand 和 Search 的成本结构不同。Expand 的 prompt 更长(要带上候选论文的完整信息),Search 的 completion 更长(要生成新检索式)。如果你发现某一档调用区间的成本突然跳升,先看那一档里 Expand 的占比——它往往意味着引用链开始"原地打转",这也是 Agent 该主动切换方向、而不是继续深挖的信号。
8. 从环境变量到归因表:一次跑通的最小动作
把上面的东西串起来,最小可执行路径只有四步:
第一步,统一出口。去 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content=setup_entry 拿到 Key,把TAOTOKEN_BASE_URL固定为https://taotoken.net/api。
第二步,按客户端分别写配置。Claude Code 写settings.json的ANTHROPIC_*;Codex 写config.toml的 provider 段 +env_key;两套配置别互相抄变量名。需要多分支对比时,用 CC Switch 的三件套固定供应商、模型映射与切换开关。
第三步,套上采集包装。把call_model接到 PaperScout 的模型调用层,action字段老老实实标 Search / Expand,key_label一个实验分支一个值。
第四步,聚合出表。跑aggregate.py,得到调用次数区间与累计成本的对照表,和召回率曲线并排看。
做完这四步,你得到的不是"这个月花了多少钱",而是"第几次工具调用之后,边际召回开始低于边际成本"。对做 Agent 成本分析的人来说,后者才是能拿去做决策的东西。
需要先确认模型单价与可用模型清单,可以从模型对话入口跑一条最小请求验证链路:
- 模型对话:https://taotoken.net/models/detail/chat?utm_source=taotoken_aicg_blog_end&utm_content=chat_verify
- Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=plan_multiturn
- 创建 Key:https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=create_key
- Claude Code 文档:https://taotoken.net/doc/ClaudeCodeAnthropic?utm_source=taotoken_aicg_blog_end&utm_content=claude_code_doc
建议顺序是先用模型对话验证 Key 与 Base URL 能通,再按用量选 Coding Plan,然后到控制台创建按实验分支命名的 Key,最后照 Claude Code 文档把settings.json的env段补齐。四步走完,你的第一张"工具调用次数 × Key × Base URL"归因表就可以开始跑了。