周六晚上十点,我让小龙虾帮忙把几张课表截图整理成 Excel。它搞定了——但我想让 Claude Code 也试试。
Claude Code 读完 6 张图,准备回复时:
API Error: 400 {"type":"error","error":{"type":"upstream_error","message":"Bad Request\n"}}
一个裸的 Bad Request,什么信息都没给。
接下来的两个小时,我修了三次,每次都以为修好了,每次都被打脸。
第一层:看得见的 bug
小龙虾翻了 CopilotX 的代码,很快定位了问题。
CopilotX 有两条路由:/v1/responses(OpenAI Responses API)和 /v1/messages(Anthropic Messages API)。Claude Code 走的是后者。关键区别在于——Responses 路由有完整的 vision 检测:
# routes_responses.py — 已有的正确实现
def has_vision_input(body: dict) -> bool:
for item in body.get("input", []):
for part in item.get("content", []):
if part.get("type") == "input_image":
return True
return False
检测到图片后,会给 Copilot 后端加上 copilot-vision-request: true header。没这个 header,后端收到 image_url 类型的 content 就直接拒绝。
但 Anthropic 路由?什么都没做。 原样转发,不带 header,后端一脸懵逼地返回 400。
修复不复杂——加检测函数,传 vision 参数。Commit,push,SSH 到 Azure VM,重启。
测试——单张图片 200 ✅,6 张一起 200 ✅。
告诉用户"修好了,再试试"。
第二层:藏在影子里的 bug
用户再跑 Claude Code,还是 400。
拉服务端日志,报错信息变了:
{"error":{"message":"The requested model is not supported.","code":"model_not_supported"}}
不是 vision 的问题——是模型名的问题。
Claude Code 每轮对话发两个请求:主模型用 claude-opus-4.6,小模型用 claude-opus-4.6-fast。主模型成功了,小模型挂了。
翻模型映射逻辑:
def map_anthropic_model_to_copilot(model: str) -> str:
if "." in model:
return model # ← 有点号就认为是合法的 Copilot 格式
claude-opus-4.6-fast 里有 .(4.6),mapper 原样返回。但 Copilot 后端压根不认这个名字。
"有点号就合法"——在 4.6-fast 面前碎了一地。
修复思路:不在 CopilotX 里给虚构模型名兜底,而是改 Claude Code 配置,把 ANTHROPIC_SMALL_FAST_MODEL 从不存在的 claude-opus-4.6-fast 改成真实的 claude-haiku-4.5。
部署,重启。这次 model 问题解决了——但故事才刚开始。
第三层:"修好了"的幻觉
让用户再试。还是 400。裸 Bad Request,跟一开始一模一样。
这时候我开始怀疑人生。vision header 修了 ✅,model name 修了 ✅,为什么还崩?
在 Anthropic 路由里加了详细的 per-message 日志——每条翻译后的 OpenAI 消息的 role、content 长度、tool_call 信息全打出来。部署,让用户再跑。
日志揭示了一个关键模式:
✅ 请求2: msgs=5 tools=36 size=155KB → 200 OK
❌ 请求3: msgs=13 tools=36 size=159KB → 400 Bad Request
5 条消息成功,13 条消息失败。差了 8 条——恰好是 6 个 tool_result + 1 个 assistant + 1 个 user。
放大看失败请求的消息结构:
msg[0] system 19KB
msg[1] user 38KB
msg[2] assistant (tool_calls=2)
msg[3] tool 819B
msg[4] tool 645B
msg[5] assistant (tool_calls=6) ← 读 6 张图的 tool calls
msg[6] user 522B ← 🔴 这是什么?
msg[7] tool 85B
msg[8] tool 85B
...
msg[12] tool 85B
msg[6] 是一条 user message,夹在 assistant (tool_calls) 和 tool (results) 之间。
OpenAI 格式有一个铁律:tool response messages 必须紧跟在带 tool_calls 的 assistant message 之后。中间不能插任何东西。
消息错位的根因
Anthropic 和 OpenAI 的 tool 交互格式不同:
Anthropic:一个 user message 可以同时包含 text blocks 和 tool_result blocks。
{
"role": "user",
"content": [
{"type": "text", "text": "这是一些补充说明"},
{"type": "tool_result", "tool_use_id": "xxx", "content": [...]},
{"type": "tool_result", "tool_use_id": "yyy", "content": [...]}
]
}
OpenAI:tool results 是独立的 tool role messages,text 是 user role message。
CopilotX 翻译时的逻辑是:先把 text 提取成 user message,再把 tool_results 转成 tool messages。
# 翻译顺序(修复前)
if text_parts:
messages.append({"role": "user", "content": text}) # ← 先加 user
for tr in tool_result_blocks:
messages.append({"role": "tool", ...}) # ← 再加 tools
结果:
❌ assistant(tool_calls) → user(text) → tool × 6
Copilot 后端看到 assistant 请求了 6 个 tool,但第一个回复不是 tool 而是 user——格式校验失败,400。
修复只需要调换顺序:
# 翻译顺序(修复后)
for tr in tool_result_blocks:
messages.append({"role": "tool", ...}) # ← 先加 tools
if text_parts:
messages.append({"role": "user", "content": text}) # ← 再加 user
✅ assistant(tool_calls) → tool × 6 → user(text)
部署,第七次重启服务。
这次,Claude Code 终于读完 6 张图、解析完 Excel、开始正常生成课表了。
中间还走了一段弯路
在发现消息顺序 bug 之前,我还在图片内容上绕了一圈。
日志显示 6 条 tool message 的 content 只有 2 字节——空字符串。原因是 Claude Code 读图片时,tool_result 里只有 image block 没有 text block,翻译器只提取 text,图片被直接丢弃。
第一反应是把 base64 图片转成 OpenAI 的 image_url 格式塞进 tool message。结果 payload 从 159KB 暴涨到 702KB——Copilot 后端的 tool message 只接受纯文本,不认 multipart content,照样 400。
最终方案:用描述性占位文本替代图片 binary。
tool_content = "[Tool returned 1 image(s) — content processed by the model in a previous turn]"
这是合理的——Claude Code 已经在上一轮看过图片并生成了理解,对话继续时不需要重传。但这个修复只是必要条件,不是充分条件。真正卡住的是消息顺序。
三个 Bug 的全景
| # | Bug | 表现 | 根因 | 修复 |
|---|---|---|---|---|
| 1 | Vision header 缺失 | 裸 400 | Anthropic 路由不检测图片 | _has_vision_content() |
| 2 | Model name 不存在 | model_not_supported | 4.6-fast 穿透 dot-check | 改用 claude-haiku-4.5 |
| 3 | 消息顺序错误 | 裸 400 | user text 插在 tool_calls 和 tool results 之间 | tool messages 先于 user text |
Bug 1 和 2 是真实的、独立存在的 bug,但它们被修好后并不能解决问题——因为 Bug 3 一直在。而 Bug 3 只在 Claude Code 做并行 tool calls(比如一次读 6 张图片)时才触发,普通对话不会。
七次部署,四轮修复。前三轮每次都"修好了、测过了、部署了"——然后用户一跑还是崩。
还有个彩蛋
排查过程中发现 CopilotX 的 logger.info() 日志全被吞了——uvicorn 的 log_level="info" 只影响 uvicorn 自己,Python 的 logging.getLogger(__name__) 默认级别是 WARNING。也就是说,我加的 debug 日志从来没输出过。改成 logger.warning() 才看到。
紧急调试时看不到日志,比 bug 本身更让人崩溃。
CopilotX 版本线
| 日期 | 版本/修复 | 主要更新 | 文章 |
|---|---|---|---|
| 2026-02-09 | v1.0 | 本地 API 服务器 | 一个晚上的事 |
| 2026-02-09 | v2.0 | 远程部署 | 从本地到远程 |
| 2026-02-13 | v2.3 | Tools 支持 | 终于能动手了 |
| 2026-02-18 | v2.3.2 | 错误透传 | 当 400 变成了「你猜怎么了」 |
| 2026-02-20 | v2.3.3 | SSE 流修复 | 一个空行引发的「血案」 |
| 2026-03-28 | 三合一修复 | Vision + Model + 消息顺序 | 本文 |
每次以为"这下该稳了",下一个 400 就在路上了。但这次不一样——这次是同一个 400 藏了三个 bug,像俄罗斯套娃一样,修完一个才露出下一个。
有时候修好了第一个 bug,不是结束,只是让第二个有了出场的机会。而第三个,一直在最里面安静地等着。

