传递 previous_response_id 字段时被 JSON decoder 静默丢弃,API 返回 200 但模型完全丢失上下文,这是一个隐蔽但严重的 bug。
TL;DR:将 previous_response_id 发送给 Ollama 的 /v1/responses,请求会被接受,返回 HTTP 200,状态为 "status": "completed"、"error": null,但模型根本没有看到上一轮对话。携带 previous_response_id 的请求与不携带历史记录的请求消耗的输入 token 数量相同——在这个测试里都是 41 个——并产生同样离谱的回答。改用完整历史记录则消耗 68 个 token 且能正常工作。原因藏在源码里:请求结构体没有 previous_response_id 字段,所以 JSON 解码器直接把它丢弃了;响应结构体虽然认识这个字段,但将其设为 nil 并注释 // Not supported。由此衍生出的 bug 报告——托管模型在工具调用后返回空完成——本质上就是一个没有任何上下文的裸 function_call_output,后端面对这种输入给出了一个"没什么好说的"回复。
环境:Ollama 0.34.0,Debian 13,纯 CPU,qwen2.5:1.5b。Responses API,即 OpenAI 兼容端,Codex 及同类客户端使用的接口。第一轮让模型记住一个它不可能猜到的东西:
POST /v1/responses
{"model": "qwen2.5:1.5b",
"input": "My secret word is PINEAPPLE. Remember it. Reply with just OK."}
→ 200 status: completed input_tokens: 45 "OK"
第二轮,按照 API 文档的方式,通过引用上一次响应来继续对话:
{"model": "qwen2.5:1.5b",
"previous_response_id": "resp_578667",
"input": "What is my secret word? Reply with just the word."}
→ 200 status: completed error: null input_tokens: 41 "password"
password。来看两组对照。同样的问题,把完整历史记录放在请求体里发送:
input: [user: "My secret word is PINEAPPLE…", assistant: "OK", user: "What is my secret word?…"]
→ 200 input_tokens: 68 "PINEAPPLE"
再看同样问题,完全不带历史,只有裸的第二轮文本:
input: "What is my secret word? Reply with just the word."
→ 200 input_tokens: 41 "password"
使用 previous_response_id 的请求和不带历史的请求消耗了相同数量的 41 个输入 token,产生了同样离谱的猜测。模型两次看到的是同样的东西:只有问题,没有别的。
同样的情况在 /api/codex/v1/responses 上也成立,这是 ollama launch codex 为 Codex Desktop 建立的代理路径。41 个 token,一个猜测(这次是 Qwen),完成。
没有任何地方报错。响应不是错误,不是未完成,不是空响应。它是一个格式正确的完成响应,里面还有一个看似合理的答案。发送 previous_response_id 的对话客户端每一轮都能收到回复;只是回复针对的对话和它以为的完全不同——每次都是全新的对话。
在工具调用循环里,问题表现得更具体、更严重。第一轮:模型发出 function_call。客户端运行工具后,按照协议规定只发回这些——previous_response_id 加上 function_call_output:
轮次 1 "Call the check tool once, then reply exactly DONE." → function_call input_tokens 144
轮次 2 previous_response_id + function_call_output "test passed"
→ 200 completed "The test passed successfully! Is there anything
else you need help with?" input_tokens 144
对照组 完整历史,无 previous_response_id
→ 200 completed "DONE" input_tokens 179
第二轮回复友好、相关,但错了:指令要求说 DONE,模型根本没看到。模型看到的是一个凭空出现的工具结果,然后尽力发挥。这就是本地模型版本的表现。引发这个问题的上游报告是通过同一个 Codex 代理访问一个托管的 :cloud 模型,对同样的裸 function_call_output 得到的是空的 output_text,且 input_tokens: 0——Codex Desktop 随后将其视为一个干净结束的轮次。在本地模型上这个确切症状没有复现,我也没有测试托管模型。真正复现出来的是底层问题:上下文丢了,后端对没有上下文的工具结果如何处理,完全取决于后端。
openai/responses.go,Ollama 0.34.0。请求类型:
type ResponsesRequest struct {
Model string
Input json.RawMessage
Instructions *string
Tools []ResponsesTool
...
// no PreviousResponseID field
}
Go 的 encoding/json 会忽略没有匹配字段的键,除非设置了 DisallowUnknownFields。没有设置。所以 previous_response_id 在解码阶段就被丢弃了,任何 handler 都看不到它。
而响应类型倒是认识这个字段:
PreviousResponseID *string `json:"previous_response_id"`
...
PreviousResponseID: nil, // Not supported
所以每条响应都带着 "previous_response_id": null。这是整个交互中唯一诚实的信号,然而没有任何客户端会回头读取这个字段——OpenAI 的实现会回显你发送的 id,所以根本没什么可检查的。实现这个功能的功能请求 ollama/ollama#15954 自今年五月就开放至今。
因为很容易搞错。/api/codex/v1/responses 不是一个独立的实现。它是一个路由器:请求中指定的模型如果出现在 ~/.codex/ollama-launch-codex-routing.json 里,就转发给 Ollama 自己的 /v1/responses;其他模型则转发给 OpenAI。在一个没有路由文件的新安装上,该端点返回 503 read Codex Ollama model catalog … no such file,这不是 bug,只是 ollama launch codex 还没运行过。自己手动写一行目录文件,代理就会把本地模型当作托管模型一样路由——而 previous_response_id 也同样会被丢弃,因为丢弃发生在下游。
Ollama 里没有任何机制恢复状态,所以客户端必须停止期待它。每一轮都发送完整对话:
{"model": "qwen2.5:1.5b",
"input": [
{"role": "user", "content": "…轮次 1…"},
{"role": "assistant", "content": "…轮次 1 回复…"},
{"type": "function_call", "call_id": "call_x", "name": "check", "arguments": "{}"},
{"type": "function_call_output", "call_id": "call_x", "output": "test passed"}
]}
这就是上面消耗 68 token / 179 token 的对照组,在两条路径上都有效。大多数 OpenAI 兼容客户端本来就是这样做的;没有这样做的那些是针对 Responses API 的有状态模式构建的——Codex 是其中最突出的代表,所以上游报告来自 Codex Desktop。
在信任一个 Responses 服务器的状态之前先验证它。只需要四个请求:
import json, secrets, urllib.request
URL, MODEL = "http://127.0.0.1:11434/v1/responses", "qwen2.5:1.5b"
def post(b):
r = urllib.request.Request(URL, data=json.dumps(b).encode(), headers={"content-type":"application/json"})
return json.load(urllib.request.urlopen(r))
def text(d): return " ".join(c["text"] for o in d["output"] if o["type"]=="message" for c in o["content"])
word = "".join(secrets.choice("BCDFGHJKLMNPQRSTVWXZ") for _ in range(7))
plant, ask = f"My secret word is {word}. Reply with just OK.", "What is my secret word? Reply with just the word."
r1 = post({"model": MODEL, "input": plant, "stream": False})
r2 = post({"model": MODEL, "previous_response_id": r1["id"], "input": ask, "stream": False})
r4 = post({"model": MODEL, "input": ask, "stream": False})
print(word, "| via id:", r2["usage"]["input_tokens"], repr(text(r2)), "| no history:", r4["usage"]["input_tokens"], repr(text(r4)))
LNLPFVF | via id: 41 'Unlimited possibilities' | no history: 41 'Password'
如果带 id 的请求和不带历史的请求消耗相同 token 数,且都不知道那个词,那么服务器就没有在维护状态,不管 status 字段怎么说。工具包里的 check-responses-state.sh 在此基础上还跑了完整历史记录的对照组,所以太小的模型记不住这个词会被报告为 unknown,而不是 pass 或 fail。
窄一点说:completed 状态描述的是服务器处理的请求,而不是你发送的请求。这里服务器处理的是"我的秘密词是什么?",并完美完成了它。previous_response_id 没有被拒绝,没有收到警告,也没有收到 incomplete_details 通知。等到有任何地方可以投诉的时候,它已经不在了。
宽一点说:这是一个零成本的对照实验。这次诊断里,token 数量做了所有工作。两个本应携带不同量级上下文的请求消耗了相同的 token,而另一个本应与第一个携带相同上下文的请求却消耗了更多。那个数字在每条响应里都有,而且是精确的,不依赖于对模型输出的解读。当一个有状态的 API 可能其实无状态时,先数 token,再读文字。这和固定 prompt、移除约束、看字节是否变化是同一套动作:找到那个如果功能真实现了就一定会变的数字,然后观察它有没有动。
本文的修复已作为经过测试、可直接运行的脚本收录在工具包里。