调 LLM 接口:不带历史时它没说「不知道」,它编了一个

调 API 与上下文计费第 1 / 2 篇

本文的每个数字都是跑出来的。 商业 API 那部分是 @anthropic-ai/claude-agent-sdk@0.3.260 + claude CLI 2.1.258,模型 claude-haiku-4-5;本地那部分是 Ollama + qwen2.5:7b-instruct-q4_K_M。 验证代码在仓库 experiments/llm-api/。

🚨 这篇的标题和大纲不一样,因为有一条验证手段我做不到,换了做法。 原计划是「用 curl 跑通一次最小请求」—— 而我发不出那个 curl: 这台机器没有 API key,SDK 借的是已登录 claude CLI 的凭据, 那条凭据没有暴露给别的程序的出口。 换掉的是谁发这个请求:改成抓下 SDK 发出的真实请求再逐字段拆。 JSON 是真的、错误码是真的,但没有一个请求是我自己构造的。 ⚠️ 也就是说:下面那些字段说明你可以信,但本文没有一条能直接复制去跑的 curl 命令。 (明确没走的一条路:让代理把凭据注入到我自己的 curl 上。 那等于把发给一个客户端的凭据转给另一个程序用,为一篇文章不值得。)

复核:2026-09-07(SDK 升到 0.3.263,CLI 仍 2.1.258)

✅ 本文赖以成立的那个前提仍然成立:这台机器无 API key 时 SDK 照样能跑(借已登录 CLI 的凭据),而塞一个格式合法的无效 key 会硬失败在 401、不回退 —— 也就是说上面那段「我发不出那个 curl」的处境没有变。

⚠️ 只重跑了凭据探针(experiments/agent-sdk/probe-auth.mjs), 没有重新抓包:正文第 169 行那类逐字段的数字(如 11,140 token 的前缀构成) 是写作那一轮的记录,本次没有重新量。

📌 顺带一个提醒:SDK 两个补丁版之间,「最小请求」的缓存前缀就从 17,272 变成了 17,025。这类前缀数字随版本飘,别把它当常量引用。

一、先把判据做掉:「LLM 有记忆」是错的

两轮对话。第一轮告诉它一个猜不出来的四位验收码 K3M9 (猜不出来是关键 —— 答对就只可能来自上下文,不可能来自先验), 第二轮问它刚才那个码是什么。

同一个第二轮问题,两种发法,各跑 10 次 (故意不设 temperature=0 —— 设了 10 次必然全同,那个 10 就没意义了):

发法 答对 10 次里有几种不同回答
带全部历史(user → assistant → user) 10/10 1 种:K3M9
只带最后一轮(只发第二个 user) 0/10 2 种

不带历史时那 2 种回答是:

"验收码是:ABCD"
"验收码是:abcd"

🚨 它没有说「你没告诉过我」。它编了一个格式完全正确的假答案。

⭐ 这个失效形态和上下文窗口那篇 里 num_ctx=64 编出 "1234" 是同一个形状: 信息不在上下文里的时候,模型的默认行为不是承认,是补一个看起来合理的。 而“格式正确、内容是编的”这一类,下游任何校验都拦不住。

⇒ 判据成立:模型这一端是无状态的。「它记得我们聊过什么」是客户端制造的错觉。

二、那个错觉是怎么造出来的:每轮重发全部历史

既然模型不记得,多轮对话只能靠一件事:每次请求都把之前所有轮次重发一遍。

这在抓包里看得很直白 —— 上一篇量过,同一次运行的四个请求:

第几次请求 messages 条数
1 1
2 3
3 5
4 7

每问一轮,messages 长 2 条(你的 + 它的)。 你付的钱里,很大一部分是在反复重发同一段历史。 (缓存能让重发的部分便宜很多,但它仍然要被发送、要被计入请求。)

三、把一次真实请求逐字段拆开

发的是一句 17 个字符的问题:「用一句话说明「无状态」是什么意思。」 allowedTools: [],并且按上一篇的结论加了隔离 (settingSources: [] + strictMcpConfig: true)。抓下来是这样:

字段 字符数 占比 是什么
tools 72,272 87.2% 可用工具的完整定义(名字 + 描述 + JSON Schema),每轮重发
messages 10,173 12.3% 整个对话历史
metadata 193 0.2% 调用方标识,不影响生成
system 191 0.2% 系统提示词,与 messages 分开,不占对话轮次
context_management 39 0.0% 超长时怎么处理
thinking 29 0.0% 思考配置
model 16 0.0% 要哪个模型
max_tokens 5 0.0% 输出上限,到顶会截断
stream 4 0.0% 是否流式(SDK 恒为 true)
82,922 合计

82,922 字符里,我写的那句话占 17 个 —— 0.02%。

而 messages 那 10,173 字符里,我的问题也只是最后一小块。展开看:

块 字符 内容
[0] 1,880 <system-reminder> 可用的 agent 类型清单
[1] 5,933 <system-reminder> 可用的 skills 清单
[2] 87 <system-reminder> 剩余 token 预算
[3] 2,150 <system-reminder> 上下文(见第五节 🚨)
[4] 17 我的问题

🚨 usage.input_tokens 是 10 —— 而真实输入是 24,613

同一次请求的响应里:

"input_tokens": 10,
"cache_creation_input_tokens": 24613,
"cache_read_input_tokens": 0

那个叫 input_tokens 的字段只有 10。 如果你的日志记的是它,你会以为这次请求几乎没有输入 —— 实际进模型的是 24,623 个 token,只是其中 24,613 个走了缓存创建那一栏。

⇒ 统计输入量要把三个 *input_tokens 加起来, 只看 input_tokens 会把一个 82KB 的请求记成一个 10 token 的请求。

🚨 我第一版的表是错的,而它看上去很自洽

第一次跑出来,tools 是 28,338 字符、占 93.7%,合计 30,238。 现在这张表是 72,272 / 87.2% / 82,922 —— 整体低报了 2.7 倍。

原因在我的抓包代理:它把超过 400 字符的字符串折叠成 {_elided, chars, sha256} 以免日志爆掉,而我的统计函数用的是 JSON.stringify(v).length —— 数的是那个折叠后的壳,不是 chars 里记的原长。

⚠️ 最难发现的地方在于:tools 和 messages 是两个最大的字段, 它们被同时压扁了,于是比例看上去还挺合理(93.7% 对 4.4%), 表本身自洽,没有任何一处显得不对。

判别方法很土但有效:把一个字段展开逐块相加,和顶层数字对一下。 messages 顶层报 1,327,逐块相加是 10,067 —— 差 7.6 倍,问题就暴露了。

别名在服务端被换成了快照

请求里写的 model : claude-haiku-4-5
响应里回来的     : claude-haiku-4-5-20251001

⇒ 记日志时记响应里那个,不然你无法回答「上周那次跑的到底是哪个版本」。

四、超限时,你会看到两层不一样的错误

把资料填到 795,139 字符(约 199K token)再发一次。 服务端返回的是:

HTTP 400
{"type":"error",
 "error":{"type":"invalid_request_error",
          "message":"prompt is too long: 211140 tokens > 200000 maximum"},
 "request_id":"req_011CehpbKYgpX9anjyWMuYes"}

而 SDK 交到你手里的是另一句话:

Prompt is too long · the request is ~211140 tokens (limit 200000) and this
conversation's own content is most of it. A single-exchange conversation
cannot be compacted; start with less content (smaller files or pasted text).

两层都有用,但能拿去排查的是下面那层:invalid_request_error 这个类型名、以及那个 request_id(找人查问题时要给的就是它)。 SDK 那层是给人看的,字段结构不保证稳定。

📌 三个顺带的事实:

  • 795,139 字符 ≈ 211,140 token,约 3.77 字符/token(纯 ASCII 填充)。 注意我只填了约 199K,而请求报 211,140 —— 多出来的一万多是那 72KB 工具定义。
  • 被拒的请求 usage 全是 0,没有计费。
  • SDK 这一层的 result.subtype 仍然是 'success',只有 is_error: true —— 和前面那篇量到的是同一个坑。

五、🚨 隔离开关关不掉的那 2,150 个字符

上面第三节那个表里的块 [3],在我这台机器上是 2,150 字符,内容是 我的私人自动记忆索引全文(~/.claude/projects/<项目>/memory/MEMORY.md 里那十几条笔记的标题与摘要)加上我的邮箱地址。

⚠️ 而我已经按上一篇的结论加了隔离参数。四种关法全部无效:

尝试 块 [3] 大小
settingSources: [] 2,150(无变化)
+ strictMcpConfig: true 2,150
+ managedSettings: { autoMemoryEnabled: false } 2,150
+ managedSettings: { autoMemoryDirectory: <空目录> } 2,150
把 cwd 换到 /tmp(那里没有项目记忆) 593 ✅

593 那一档里已经没有 # claudeMd 段了,只剩邮箱和日期。

⇒ 能改变这件事的只有 cwd。 我没有找到一个受支持的开关能关掉它 —— autoMemoryEnabled 这个设置项在类型定义里存在(Settings), 但通过 managedSettings 传进去对这段注入没有作用。

📌 实用含义:如果你用 Agent SDK 写一个会把请求体记进日志、 或者发给第三方服务的程序,先看一眼 messages[0] 里有什么。 它可能带着这台机器上属于你个人的东西,而这不是 allowedTools 或 settingSources 管得住的。 (本文的抓包产物因此不入库,理由和可观测性那篇第四节一样。)

⚠️ 需要说清楚的边界:这些内容是发给同一家厂商、同一个账号的 ——和你平时用 Claude Code 时发过去的是同一个地方。 所以这不是“泄漏给第三方”,而是隔离选项的实际作用范围比名字看上去小。

六、能带走的五条

  1. 模型是无状态的。 多轮靠每次重发全部历史; 不带历史时它不会说“不知道”,会编一个格式正确的。
  2. 统计输入量要把三个 *input_tokens 加起来。 只看 input_tokens 能把 82KB 的请求记成 10 token。
  3. 请求体里你写的那句话可能占不到 0.1%。 剩下的是工具定义和注入的上下文。
  4. 排查要用服务端那层错误(invalid_request_error + request_id), 不是 SDK 包装后的那句人话。
  5. 量长度的时候,先确认你量的是内容还是壳。 两个大字段同时被压扁时,比例仍然自洽,表看不出问题。

七、没有回答的问题

  • 本文没有一条能直接跑的 curl。 字段说明来自抓包, 拿到 key 的人应该自己发一次最小请求核对一遍 —— 那才是这篇原本该有的样子。
  • autoMemoryEnabled 到底该怎么传才生效? 我只试了 managedSettings 这一条通道,没试过写进磁盘上的 settings 再配 settingSources (那与“隔离”的目的相悖,但能回答机制问题)。
  • 3.77 字符/token 只对纯 ASCII 填充成立。 中文的比值完全不同,本文没测。
  • 超限的边界在哪一侧判定:服务端返回了 400, 但我没有测「刚好 200,000」和「200,001」两档,所以不知道边界是闭区间还是开区间。