ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

终端编码助手对接大模型:400错误与请求体协议转换排查

终端编码助手对接大模型:400错误与请求体协议转换排查

400 这个状态码,写过 Web 接口的人都不陌生,它基本等于“你请求体的参数不对”。可一旦把它放到终端编码助手这个场景里,事情就变得有点拧巴:本地终端里跑的那个交互式助手,说的是某一种接口方言;你对接到的那台国产大模型 V4,说的是另一种方言。两边各自都“没毛病”,但拼在一起就是过不去。我前阵子帮人排查这个问题,来回折腾了大半天,从怀疑密钥、怀疑网络、怀疑版本,最后发现纯粹是请求体的结构对不上——这类问题特别消耗耐心,因为报错信息通常只给你一句“invalid request”,连具体字段名都不给。

这篇内容就是把这半天的排查过程完整摊开讲。核心关键词是400 错误、请求体结构不匹配、接口协议转换、模型名与参数越界、tools schema 兼容性。它适合三类人看:正在把终端编码助手接到别的模型上、结果一直卡在 400 的人;自己写网关做协议转换、被各种奇奇怪怪报错折磨的人;以及单纯想搞清楚“为什么换个模型就得改一堆东西”的开发者。文中不会给你一个万能配置就完事,而是把每一类 400 背后的字段级原因拆开讲,让你下次能自己定位,而不是靠猜。

为了行文方便,下面把终端里那个交互式编码助手统称为CLI 助手,把对端接的国产大模型 V4 统称为V4。CLI 助手的各种环境变量名,各家实现不完全一样,我用CLI_BASE_URL、CLI_API_KEY、CLI_MODEL这种通用写法代替,你自己对照手头那份文档替换即可。

1. 先把 400 的锅分清楚:报错链路上到底谁在拒绝

很多人一看到 400 就去翻密钥,其实这个方向从第一步就偏了。密钥错了是 401,余额不足是 402 或 429,权限不够是 403,模型服务崩了是 500 或 502。400 的语义非常明确:服务端收到了你的请求,也认出了你是谁,但你请求体的内容它读不懂或者不接受。换句话说,认证这一层你已经过了,问题在报文本身。

1.1 一次调用要穿过几道关卡

CLI 助手发起一次请求,中间至少要过三道关,每一道都可能吐 400,但吐出 400 的“口吻”不太一样,熟悉了其实能分辨出来。

第一道是你本地。CLI 助手在组装请求体的时候,会塞进去一堆东西:系统提示词、工具定义、历史消息、缓存标记、思考块配置。这些字段是按它自己对接口协议的理解生成的。你在配置里指定了 base URL 指向某个兼容层,它就把这一坨原封不动发过去。

第二道是中间的转换层或网关。如果对端只提供 OpenAI 风格的接口,而 CLI 助手说的是另一套方言,中间就必须有个东西做翻译。这个翻译层是有损的——它不可能把两边所有字段都一一映射,遇到自己不认识的字段,处理方式通常是直接丢掉,或者原样透传。原样透传是最危险的,因为对端不认识这个字段,直接给你一个 400。

第三道才是 V4 本体。它拿到请求体,做参数校验:模型名对不对、max_tokens是否越界、工具 schema 是不是合法 JSON Schema、消息序列是不是符合角色交替规则。任何一条不合规,返回的就是那句干巴巴的 400。

所以你在终端看到的那个 400,可能是三道关里任何一道发出的。很多人吃亏就吃在:盯着第三道的文档改配置,结果问题出在第二道的转换逻辑上。

1.2 为什么 401 好查、400 难查

401 好查,因为原因基本唯一:密钥没配对、过期了、或者前缀少了。400 难查,是因为它的可能原因有十几种,而且服务端出于安全考虑,通常不会告诉你具体是哪个字段挂了。你只会看到类似这样的输出:

API Error: 400 {"error":{"message":"Invalid request body","type":"invalid_request_error"}}

这句话的信息量几乎为零。剩下全靠你自己二分定位。

我一般的做法是:先把 CLI 助手那层剥掉,用 curl 或脚本直接打对端接口,用最小请求体确认“服务本身能通”。通了,再一层层往上加字段,加到哪一步开始 400,问题就锁定了。这个思路听起来笨,但比盲改配置快得多,因为它是可控的二分,不是每次改完等十几秒看结果。

提示:排查期间把 CLI 助手的流式输出关掉(如果有这个选项)。流式模式下报错信息经常被截断在半个 JSON 里,你看到的{"error":{"message":"Inv后面就没了,反而增加误判。

1.3 先建立一张“责任田”地图

在动手改任何东西之前,我建议先画一张表,把每个字段归属哪一方写清楚。这不是形式主义,而是能帮你判断“这个字段该不该出现在请求里”。

字段CLI 助手会发对端 V4 是否认识典型处理方式
model会发,且写死某个名字只认自己的模型名必须改写
max_tokens必填认识但有上限超限即 400
system顶层字符串或块数组OpenAI 风格只认 messages 里的 system必须搬迁
tools[].input_schema一套结构只认function.parameters必须重命名映射
tool_use/tool_result块消息内容里的块只认tool_calls和 role=tool必须拆解重排
cache_control会带大概率不认识建议直接删
thinking可能带视版本而定先删了再试
metadata.user_id可能带不认识删

这张表做完,你会发现问题集中在“结构性字段”,而不是“值”。值错了往往报错很明确(比如模型名不存在),结构性字段错了才是不吭声的 400。

2. 协议层的错位:两套接口方言硬碰硬

真正让我卡住的地方,是两套接口在消息模型上的根本差异。这不是字段名换个写法就能解决的,它牵扯到“工具调用”这个概念怎么表达。CLI 助手重度依赖工具调用,它每一次读文件、执行命令,都是一次 tool 往返。工具调用格式对不上,400 就躲不掉。

2.1 请求体长得根本不像

我先把两边的最小请求体摆出来对着看,你自己感受一下差异有多大。

CLI 助手这一侧,请求体大致是这种形状:

{ "model": "some-model-name", "max_tokens": 8192, "system": [ {"type": "text", "text": "你是一个编码助手……", "cache_control": {"type": "ephemeral"}} ], "messages": [ {"role": "user", "content": [{"type": "text", "text": "读一下 config.json"}]}, {"role": "assistant", "content": [ {"type": "text", "text": "好的"}, {"type": "tool_use", "id": "toolu_01", "name": "read_file", "input": {"path": "config.json"}} ]}, {"role": "user", "content": [ {"type": "tool_result", "tool_use_id": "toolu_01", "content": "{\"a\":1}"} ]} ], "tools": [ {"name": "read_file", "description": "读取文件", "input_schema": {"type": "object", "properties": {"path": {"type": "string"}}}} ] }

对端 V4 那一侧,如果是 OpenAI 风格,期望的是:

{ "model": "v4-chat", "messages": [ {"role": "system", "content": "你是一个编码助手……"}, {"role": "user", "content": "读一下 config.json"}, {"role": "assistant", "content": "好的", "tool_calls": [ {"id": "call_01", "type": "function", "function": {"name": "read_file", "arguments": "{\"path\":\"config.json\"}"}} ]}, {"role": "tool", "tool_call_id": "call_01", "content": "{\"a\":1}"} ], "tools": [ {"type": "function", "function": {"name": "read_file", "description": "读取文件", "parameters": {"type": "object", "properties": {"path": {"type": "string"}}}}} ] }

看到区别没有?同样的语义,结构上至少有五处不一样:system 的位置、tools 的嵌套层级、工具调用的表达方式(内容块 vs 独立字段)、工具结果的承载方式(user 消息里的块 vs 独立的 tool 角色)、以及工具 id 的对齐关系。任何一处没转对,对端要么报 400,要么返回一堆莫名其妙的结果。

2.2 转换层最容易漏掉的三个细节

我自己写转换层的时候,前两版都是“能跑但时不时 400”。后来复盘,发现漏的是这三个细节,都属于那种文档里不会强调、但实际必踩的。

第一个细节:is_error标记。CLI 助手在工具执行失败时,会在tool_result块里带一个is_error: true。这个字段在 OpenAI 风格里没有对应物。如果原样透传,对端不认识,可能直接 400。正确做法是把它吞掉,同时把错误信息写进 content 文本里(比如加个前缀“执行失败:”),让模型从文本上判断,而不是靠一个它不理解的结构化字段。

第二个细节:content既可能是字符串也可能是数组。CLI 助手发的tool_result里,content有时是纯字符串,有时是[{"type":"text","text":"..."}]这样的块数组。转换层如果只处理了字符串分支,遇到数组分支就会把整块塞进消息里,对端解析失败直接 400。这个分支一定要写成:无论哪种形态,最后都摊平成字符串。

第三个细节:连续同角色消息的合并。一次工具往返结束后,CLI 助手可能会连着发两条 user 角色消息(一条放 tool_result,一条放新的用户输入)。OpenAI 风格对消息序列的容忍度视实现而定,有些严格实现要求 user 和 assistant 交替,连续两条 user 直接判为非法。稳妥的转换策略是:在把tool_result转成 role=tool 之后,如果后面紧跟一条 user 文本消息,就让它们各自独立成条,不要人为合并,也不要干预顺序。

提示:转换层写完第一版,别急着接上 CLI 助手跑。先拿一份真实的请求体存成 JSON 文件(可以在转换层入口打日志捞),然后写个脚本反复喂给转换函数,断言输出结构。这比在终端里点来点去快十倍。

2.3 用二分法定位到具体字段

字段一多,靠猜就没意义了。我常用的手法是“从最小可用体开始,逐步加字段”。具体操作:先准备一个绝对能通的最简请求,只带模型名和一条用户消息,确认返回 200。然后逐组加字段,每组加完打一次,看哪里开始崩。

被加回去的顺序建议是这样,从概率最高的开始:

  1. messages里的普通文本消息(几乎不会出问题,用来验证基础连通)
  2. system消息(验证搬运逻辑)
  3. 单个工具定义(验证 schema 映射)
  4. 一次带 tool_calls 的 assistant 消息(验证工具调用块转换)
  5. 一条带 tool_call_id 的 tool 消息(验证 id 对齐)
  6. 历史消息全套(验证长序列不会被判非法)

这套顺序的好处是,每一步的失败都能直接指向一类转换逻辑,而不是笼统的“挂了”。

3. 六类高频 400:特征、成因、改法

把排查范围收窄之后,我发现真正高频的就那么六类。下面逐条拆,每条都给出典型的报错特征和具体的改法。

3.1 模型名不存在:最常见的“假 400”

按道理模型名不对该返回 404,但很多兼容层图省事,统一返回 400 加一句“model not found”。这类报错其实最好认,因为它通常会在 message 里带上你请求的模型名。

常见的写错方式有三种:一是直接用 CLI 助手默认那份模型名,压根没改;二是改了一半,大小写或者分隔符不一致(有的服务用连字符,有的用下划线,有的带版本后缀);三是加了-latest之类的别名后缀,但对端只认完整名。

改法很直接:把CLI_MODEL显式设置成对端文档里列出的那个字符串,一个字符都不要改,不要自己脑补后缀,不要大小写混写。我吃过一次亏,把v4-chat写成V4-Chat,折腾了二十分钟才发现。

还有一个坑:有些服务对“普通对话模型”和“推理模型”是两个不同的名字,推理模型可能在参数约束上更严格(比如不接受温度调节、要求思考预算)。如果你只是想让 CLI 助手跑通基本功能,先接普通对话模型,别一上来就上推理版本。

3.2 max_tokens 越界:参数校验最先撞的墙

CLI 助手默认的max_tokens通常设得比较大,因为它要生成代码、理解长上下文,动辄就是几千上万。但对端的上限可能远低于这个数。一旦超出,返回的就是 400,而且报错信息经常只写“invalid parameter”,不说是哪个。

我遇到过最无语的情况是:对端上限 8192,CLI 助手默认 32768,每次请求都是秒拒。改法就是把CLI_MAX_TOKENS显式降下来,比如降到 4096 或 8192,先跑通再说。

这里有个容易被忽略的点:max_tokens的语义在两边并不完全一致。有的服务把它理解为“输出 token 上限”,有的把它理解为“输入 + 输出总上限”。如果是后者,而你送进去的上下文本身就有十几 K,那即使你设的值没超过模型上限,加起来也可能超。这种情况下光调max_tokens没用,得先剪上下文。

注意:不要为了绕过报错把max_tokens设成 1 或者很小的值。有些服务对下限也有校验,太小反而报“参数超出合法范围”。合理区间一般是 1 到模型上限之间,实际用 2048 到 8192 比较稳妥。

3.3 tools schema 不合规:最难缠的一类

工具定义这块是最容易出 400 的地方,因为 JSON Schema 本身有非常多的写法,而不同实现对“支持哪些子集”的宽容度差异极大。

我踩过的一个典型:某个工具的parameters里写了"additionalProperties": false。这在前端 JSON Schema 校验里完全合法,但对端拒绝,理由是“不支持的 schema 关键字”。删掉就好了。类似的还有$ref引用、oneOf/anyOf组合、patternProperties、以及带正则的pattern。稳妥策略是只用最朴素的类型:object、string、number、boolean、array,加 properties 和 required,其余一律不用。

第二类问题更隐蔽:参数描述里带了非法字符或者超长。有的实现会对 description 做长度截断甚至校验,超过几百字符就报错。我一般把每个工具的 description 控制在 200 字以内,参数级的 description 控制在 50 字以内,能说明白就行。

第三类是工具数量过多。CLI 助手自带的工具集可能有十几个,全塞进去,有的服务会对总长度或者总数做限制。如果确认是这个问题,先只保留最基础的两三个工具(读文件、写文件、执行命令)验证连通,通了再逐步放开。

排查工具 schema 我有个笨办法很有效:写个小脚本,把tools数组里的每个工具单独抽出来,逐个构造只有这一个工具的请求,看是哪个具体的工具触发了 400。定位到具体工具之后,再逐个删字段,删到不报错为止。

import json, itertools tools = json.load(open("tools.json")) for t in tools: body = { "model": "v4-chat", "max_tokens": 64, "messages": [{"role": "user", "content": "hi"}], "tools": [{ "type": "function", "function": { "name": t["name"], "description": t.get("description", "")[:200], "parameters": t["input_schema"], }, }], } # 用你常用的 http 客户端打一次,打印状态码和响应体 print(t["name"], probe(body))

这个脚本跑一遍,哪个工具出问题一目了然,比在终端里试快得多。

3.4 system 与 content 的结构不被接受

system字段的搬迁是转换层的基本功,但也容易出岔子。CLI 助手有时发的是字符串,有时发的是块数组(带cache_control)。转成 OpenAI 风格时,要把它变成messages里第一条role: system的消息,content是纯字符串。

如果块数组里带cache_control,切记把这个字段剥掉。有的实现对未知字段零容忍,直接 400;有的虽然忽略,但如果cache_control的位置不对(比如挂在了一个它认为不该挂的块上),也会报错。

content这一侧还有个坑:空 content。CLI 助手在某些边缘情况下会发出content: ""或者content: []的消息,本地看没毛病,但对端要求 content 非空,直接 400。转换层里加一句判断:如果摊平之后是空字符串,就填一个占位文本(比如“(无内容)”),或者干脆把这条消息丢掉。

3.5 不支持的采样参数与思考块

CLI 助手默认会带temperature、top_p之类的采样参数,值通常在合理范围内。但有两种情况会翻车:

一是推理类模型不接受采样参数。有的服务对推理模型明确要求temperature必须为 1 或者干脆不许传,传了就 400。二是取值范围不同,比如有的服务要求 temperature 在 0 到 1 之间,CLI 助手默认 1.0 刚好卡在边界上,某些实现会判为越界。

处理办法:转换层里做一次收敛,把temperature夹到[0, 1]区间内,top_p同理。如果对端是推理模型,干脆整个字段删掉,让它走默认值。

思考块(reasoning 相关内容)是另一个雷区。CLI 助手如果支持“扩展思考”,可能会在请求里带一个预算字段。这个字段对端不认识,原样透传就是 400。排查阶段一律先删掉所有思考相关字段,等基础链路跑通、确认稳定了,再按对端文档决定要不要开。

3.6 上下文超长被算作参数错误

这一条容易被误判成“网络问题”或者“服务不稳定”,因为它的表现是:前几轮对话都正常,聊到某个长度之后突然开始每次都 400。

原因其实很直白:CLI 助手会维护一段对话历史,每一轮都全量发过去。历史越来越长,总 token 数超过对端上限之后,服务端校验失败,返回 400(而不是更贴切的 context_length_exceeded)。

判断方法:看看是不是“新开一个会话就正常,老会话必挂”。如果是,基本就锁定了。

改法有两条路。短期的:在 CLI 助手里执行清空上下文的命令(大部分这类助手都有/clear之类的操作),把历史扔掉。长期的:把对端的上下文上限配到 CLI 助手的配置里,让它自己知道什么时候该压缩历史。有的助手支持配一个“上下文窗口大小”的参数,把这个值设成对端真实上限的八成左右,留点余量给输出。

4. 完整落地:从零搭一个能用的转换层

光讲原因不够,下面把我实际用的那套东西完整写出来。假设场景是:CLI 助手说 A 方言,对端 V4 只说 B 方言,中间需要一层转换。我用的是一段跑在本地的轻量服务,语言选的 Node.js,因为它处理 JSON 改写最省事。

4.1 环境变量与配置文件怎么落

第一步是把 CLI 助手指向本地这一层,而不是直接指向对端。变量大致是这三组:

# 让 CLI 助手把请求打到本地转换层 export CLI_BASE_URL="http://127.0.0.1:8787" export CLI_API_KEY="local-placeholder" export CLI_MODEL="v4-chat" export CLI_MAX_TOKENS="4096" # 转换层自己去访问真正的对端 export UPSTREAM_BASE_URL="https://<你的对端地址>/v1" export UPSTREAM_API_KEY="<对端的密钥>" export UPSTREAM_MODEL="v4-chat"

几个细节值得说明。

CLI_API_KEY这里填一个占位值就行,因为认证是在转换层到对端那一段做的。填真密钥也不是不行,但没必要让它多走一跳。

CLI_MODEL和UPSTREAM_MODEL最好保持一致。虽然转换层会强制改写模型名,但让两边看起来一样,排查的时候少一层心智负担。

CLI_MAX_TOKENS我设的 4096,比对端上限低不少。这是故意的:留出余量给系统提示词和工具定义,避免“看起来没超、实际超了”的情况。

注意:如果你把CLI_MAX_TOKENS设得比转换层里的兜底值还大,一定要在转换层里做一次Math.min收敛,不能指望上游一定宽容。

4.2 请求体改写:一个够用的中间件

下面这段是我实际在用的核心逻辑,做了简化但保留了关键分支。它做四件事:搬 system、改 tools、拆工具调用块、删不认识的字段。

const http = require("http"); const UPSTREAM = process.env.UPSTREAM_BASE_URL; function flattenContent(content) { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; const parts = []; for (const block of content) { if (block.type === "text") parts.push(block.text || ""); else if (block.type === "tool_result") { const inner = flattenContent(block.content); parts.push(block.is_error ? `执行失败:${inner}` : inner); } } return parts.join("\n").trim() || "(无内容)"; } function convertTools(tools) { if (!Array.isArray(tools)) return undefined; return tools .filter((t) => t && t.name) .map((t) => ({ type: "function", function: { name: t.name, description: (t.description || "").slice(0, 200), parameters: sanitizeSchema(t.input_schema || { type: "object", properties: {} }), }, })); } function sanitizeSchema(schema) { // 只保留最朴素的关键字,其余全删 const keep = ["type", "properties", "required", "items", "enum", "description"]; const out = {}; for (const k of keep) { if (schema[k] !== undefined) out[k] = schema[k]; } if (out.type === "object" && out.properties) { for (const key of Object.keys(out.properties)) { out.properties[key] = sanitizeSchema(out.properties[key]); } } return out; } function buildUpstreamBody(body) { const messages = []; if (body.system) { messages.push({ role: "system", content: flattenContent(body.system) }); } for (const m of body.messages || []) { if (m.role === "user") { const toolResults = Array.isArray(m.content) ? m.content.filter((b) => b.type === "tool_result") : []; if (toolResults.length > 0) { for (const tr of toolResults) { messages.push({ role: "tool", tool_call_id: tr.tool_use_id, content: flattenContent(tr.content), }); } // 同一条消息里可能还有用户输入 const texts = m.content .filter((b) => b.type === "text") .map((b) => b.text) .join("\n") .trim(); if (texts) messages.push({ role: "user", content: texts }); } else { messages.push({ role: "user", content: flattenContent(m.content) }); } } else if (m.role === "assistant") { const blocks = Array.isArray(m.content) ? m.content : []; const text = blocks.filter((b) => b.type === "text").map((b) => b.text).join(""); const calls = blocks .filter((b) => b.type === "tool_use") .map((b) => ({ id: b.id, type: "function", function: { name: b.name, arguments: JSON.stringify(b.input || {}) }, })); const msg = { role: "assistant", content: text || "" }; if (calls.length) msg.tool_calls = calls; messages.push(msg); } } const out = { model: process.env.UPSTREAM_MODEL, messages, max_tokens: Math.min(Number(body.max_tokens) || 4096, 8192), stream: !!body.stream, }; if (typeof body.temperature === "number") { out.temperature = Math.max(0, Math.min(body.temperature, 1)); } const tools = convertTools(body.tools); if (tools && tools.length) out.tools = tools; if (Array.isArray(body.stop_sequences) && body.stop_sequences.length) { out.stop = body.stop_sequences.slice(0, 4); } return out; }

这段代码里有几个决定是踩过坑之后才加的,值得单独说。

flattenContent里对空字符串做了兜底,返回“(无内容)”。这不是洁癖,是因为真的遇到过对端拒绝空 content 的情况。

sanitizeSchema是白名单而不是黑名单。黑名单(删掉additionalProperties、$ref等)看起来省事,但只要对端升级一次、多支持一个关键字,你的黑名单就漏了。白名单虽然可能丢掉一些有用信息,但稳定性高得多。

max_tokens那里用了Math.min双向收敛:既防止 CLD 助手设得太大,也防止没设置的情况。

stop_sequences截到 4 个。有的服务对停止词数量有上限,超了报 400,截断比报错好。

4.3 日志怎么打才有用

转换层不打日志,等于闭着眼睛开车。但日志打太杂也没用,会被淹没。我一般只打三样东西,而且都做脱敏:

第一样:入站请求的关键结构摘要。不打全文,只打这几个数:消息条数、每条的角色序列(比如system,user,assistant,tool,user)、工具数量、max_tokens的值、tools里每个工具的名字和 schema 顶层键。这个摘要能覆盖九成的结构问题。

第二样:出站请求体的完整 JSON。这个必须打,因为 400 的时候你需要拿它去和对端文档逐字段比对。但要落文件,不要打屏,而且要按时间戳命名,方便回溯。

第三样:上游响应的状态码和响应体前 500 字符。有时候 400 的响应体里藏着关键线索,比如具体哪个字段名,只是被格式化得很难看。

function logSummary(tag, body) { const msgs = body.messages || []; console.log(`[${tag}]`, { roles: msgs.map((m) => m.role).join(","), count: msgs.length, max_tokens: body.max_tokens, tools: (body.tools || []).map((t) => t.function?.name || t.name), has_system: msgs.some((m) => m.role === "system"), }); }

这套日志在排查“同样配置,有时通有时不通”的问题时特别管用,因为你能直接对比成功和失败两次的摘要差异。

4.4 冒烟测试清单

转换层写完,别急着上真实会话。按下面这个清单一项一项过,每项都独立验证,出问题能立刻定位。

步骤验证内容预期结果失败时的第一怀疑点
1纯文本一问一答返回 200,有回复模型名、密钥、base URL
2带 system 消息返回 200,回复符合人设system 搬运位置
3带一个无参数工具返回 200tools 嵌套结构
4带一个多参数工具返回 200schema 关键字
5模拟一次完整工具往返返回 200,模型能理解结果tool_call_id 对齐
6带长历史(20 轮以上)返回 200上下文超长
7带流式能正常逐块输出流式格式转换

第 5 步是最容易翻车的,也是最关键的。CLI 助手的实际使用中,工具往返占了绝大多数请求。这一步不通,前面全白搭。

5. 排查实录:三个真实卡点与一张速查表

理论讲完,说几个我当时真卡住的地方。这些从文档里基本看不到,但实际遇到的时候特别耗时间。

5.1 卡点一:同样的配置,重启后时好时坏

这个最邪门。早上跑得好好的,下午重新打开终端就 400,配置一个字没改。

后来发现是模型名的问题。CLI 助手有个本地配置文件会缓存上一次用的模型名,我在环境变量里改了,但配置文件里的旧值优先级更高。表现出来就是“有时候生效有时候不生效”——取决于它是从环境变量读还是从配置读。

教训是:改完配置,一定要在 CLI 助手里看一眼当前生效的模型名(通常有个/status或/config之类的命令),别信环境变量。两边不一致,改环境变量等于白改。

5.2 卡点二:工具描述里有中文引号

这个坑我怀疑很少有人遇到,但确实存在。某个工具的 description 里,我写了一段带中文书名号的说明。对端校验时把它当成了非法字符,返回 400,而且报错只说“invalid tool definition”。

排查方式是逐工具二分,定位到具体工具后再逐字段删。删到 description 那一段好了,才反应过来是字符问题。后来我把所有工具描述都改成纯 ASCII 加常见中文标点,问题再没出现。

这件事给我的启发是:排查 400 的时候,不要默认“我的内容是对的”,先假设“我的内容里有对端不接受的东西”,然后逐个剥离。心态不一样,效率差很多。

5.3 卡点三:assistant 消息 content 为空

CLI 助手在“纯工具调用”的那一轮,assistant 消息可能只有tool_use块,没有文本块。转成 OpenAI 风格后,就是{role: "assistant", content: "", tool_calls: [...]}。

这个结构在多数实现里是合法的,但有的服务要求content不能为空字符串,必须给null或者干脆省略这个字段。我试了三种写法,最后发现省略最保险:

const msg = { role: "assistant" }; if (text) msg.content = text; if (calls.length) msg.tool_calls = calls;

而不是先给content: ""再覆盖。这个差别看起来微不足道,但确实解决了一类 400。

5.4 400 错误速查表

下面这张表是我自己攒的,遇到 400 先对照着看,大部分能直接命中。

报错里的关键词大概率原因快速验证方式改法
model not found/invalid model模型名不对用最简请求直接打对端照文档逐字符复制
max_tokens/length输出上限越界把max_tokens设成 1024 再试降到对端上限以下
tool/function/schema工具定义不合规单工具逐个试白名单化 schema
content/message消息结构或空内容只发一条纯文本消息补空内容兜底、检查角色序列
temperature/parameter采样参数越界删掉所有采样参数夹到 [0,1] 或直接不传
context/too long上下文超长新开会话重试清历史、调小窗口
role/sequence角色交替不合法打印角色序列看有没有连续同角色拆开或补一条 assistant
什么都不说,纯invalid request有未知字段被透传打印出站完整 JSON 逐字段核白名单构造请求体

5.5 几条压箱底的经验

别信“兼容”两个字。所谓的 OpenAI 兼容接口,兼容程度差异巨大,有的只兼容了最基础的对话,工具调用、流式、多模态全都是半成品。上手第一件事不是读文档,而是拿一个带工具的请求实测一遍,看它到底兼容到哪一层。

先把stream关掉。流式模式下的错误信息经常被截断,而且流式和非流式走的代码路径有时不一样,同一个请求非流式能通、流式就挂。排查阶段先用非流式把结构跑通,最后再开流式。

转换层要做幂等。同一份请求体喂进去两次,输出应该完全一致。如果你的转换里有随机 id 生成、有依赖时间戳的逻辑,排查时会非常痛苦,因为每次结果都不一样。

留一个“旁路模式”。我在转换层里加了个开关,打开之后不做任何改写,原样转发。这个开关在判断“到底是对端的问题还是我转换的问题”时特别好用——旁路也 400,那就是对端或者原始请求的问题;旁路能通,那就是我转换写错了。二分的方向立刻就明确了。

记录每一次成功的请求体。建个目录,把跑通的请求体存下来,出问题的时候拿它当基线,一次只改一个字段做对比。这比从零构造快得多,也更不容易漏。

对端升级后一定要重跑冒烟清单。这个我吃过亏。对端悄悄收紧了一次 schema 校验,之前跑得好好的工具定义突然开始 400,而且没有任何通知。养成习惯,每次感觉“莫名其妙就不行了”,先跑一遍那七步清单,比什么都快。

最后分享一个我自己的判断习惯:遇到 400,我会先问自己一句——这个请求体,如果我是对端的开发者,我会接受吗?站在校验方的角度过一遍字段,往往能自己发现问题,比如某个字段类型对不上、某个数组里有空对象、某个必填项缺失。这种“换个位置看”的习惯,比记住一百条报错信息管用。

返回列表