调 LLM 接口:不带历史时它没说「不知道」,它编了一个
本文的每个数字都是跑出来的。 商业 API 那部分是
@anthropic-ai/claude-agent-sdk@0.3.260+claudeCLI 2.1.258,模型claude-haiku-4-5;本地那部分是 Ollama +qwen2.5:7b-instruct-q4_K_M。 验证代码在仓库experiments/llm-api/。🚨 这篇的标题和大纲不一样,因为有一条验证手段我做不到,换了做法。 原计划是「用 curl 跑通一次最小请求」—— 而我发不出那个 curl: 这台机器没有 API key,SDK 借的是已登录
claudeCLI 的凭据, 那条凭据没有暴露给别的程序的出口。 换掉的是谁发这个请求:改成抓下 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 时发过去的是同一个地方。 所以这不是“泄漏给第三方”,而是隔离选项的实际作用范围比名字看上去小。
六、能带走的五条
- 模型是无状态的。 多轮靠每次重发全部历史; 不带历史时它不会说“不知道”,会编一个格式正确的。
- 统计输入量要把三个
*input_tokens加起来。 只看input_tokens能把 82KB 的请求记成 10 token。 - 请求体里你写的那句话可能占不到 0.1%。 剩下的是工具定义和注入的上下文。
- 排查要用服务端那层错误(
invalid_request_error+request_id), 不是 SDK 包装后的那句人话。 - 量长度的时候,先确认你量的是内容还是壳。 两个大字段同时被压扁时,比例仍然自洽,表看不出问题。
七、没有回答的问题
- 本文没有一条能直接跑的 curl。 字段说明来自抓包, 拿到 key 的人应该自己发一次最小请求核对一遍 —— 那才是这篇原本该有的样子。
autoMemoryEnabled到底该怎么传才生效? 我只试了managedSettings这一条通道,没试过写进磁盘上的 settings 再配settingSources(那与“隔离”的目的相悖,但能回答机制问题)。- 3.77 字符/token 只对纯 ASCII 填充成立。 中文的比值完全不同,本文没测。
- 超限的边界在哪一侧判定:服务端返回了 400, 但我没有测「刚好 200,000」和「200,001」两档,所以不知道边界是闭区间还是开区间。