Jev 常见报错排查与修复
每个 Jev 集成都会撞上同一小撮故障,而几乎每一个都有很短的修复路径。这篇是排查手册:HTTP 报错的”症状→原因→修复”表、占了大部分”API 坏了”报告的 JSON 解析陷阱,以及 confidence 数值不对劲时怎么办。从上往下过一遍,绝大多数问题不用提工单就能解决。
TL;DR: 401 找 Key;404 找模型 slug;429 退避减速;解析失败说明 message content 没做 JSON 加载;confidence 奇怪多半是问题太含糊。动手前先查下面的表,并第一时间到 OpenRouter 模型页确认确切的 model slug——它是最常见的元凶。
HTTP 报错:症状、原因、修复
| 报错 | 常见原因 | 修复 |
|---|---|---|
401 Unauthorized | 环境里没有 Key、变量读错、或 Key 已作废 | 在调用所在的同一 shell 打印变量;必要时到 OpenRouter 重建 Key;确认带了 Bearer 前缀 |
404 / 未知模型 | 模型 slug 写错或过期 | 到 OpenRouter 模型页确认确切 slug(写作本文时列表值为 typesafe/jev-1.13;slug 随版本变化)后重发 |
429 Too Many Requests | 触发限流或消费上限 | 指数退避加抖动;检查 Key 级限额与余额;对并发调用做批处理或节流 |
400 Bad Request | JSON 请求体格式错或缺必填字段 | 本地先校验请求体能解析;核对 OpenAI 兼容字段名(model、messages) |
5xx 服务端错误 | 渠道上游问题 | 退避重试 2–3 次,然后排队并告警;不要硬刚 |
一个最小重试封装,把 429 和 5xx 的处理收在一处:
import json, os, time, requests
def ask_jev_with_retry(prompt: str, max_retries: int = 3) -> dict:
# Confirm the exact model slug on the OpenRouter model page
for attempt in range(max_retries):
resp = requests.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={"Authorization": f"Bearer {os.environ['OPENROUTER_API_KEY']}"},
json={
"model": "typesafe/jev-1.13",
"messages": [{"role": "user", "content": prompt}],
},
timeout=30,
)
if resp.status_code == 200:
return json.loads(resp.json()["choices"][0]["message"]["content"])
if resp.status_code in (429, 500, 502, 503):
time.sleep(2 ** attempt) # 指数退避
continue
resp.raise_for_status() # 401/404/400 在这里带着上下文抛出
raise RuntimeError("Jev call failed after retries")
封装在成功时返回 fixture 形态的答案;响应形态为示例数据(example fixture),正式字段名以官方文档为准。官方 TypeSafe AI 渠道自己的错误码与重试建议,以 typesafe.ai 官方文档为准。
解析失败:常见嫌疑人
大部分”API 坏了”的报告,最后都发现是客户端这边的坑:
- 把 content 当字典用。 message content 是 JSON 字符串。
resp.json()["choices"][0]["message"]["content"]拿到的是字符串;访问answer、confidence之前先json.loads。 - 把响应壳当答案。
answer在解析后的 content 里,不在 HTTP 响应的顶层。HTTP 壳是 OpenAI 兼容的,判断载荷在里面——示例数据(example fixture),正式字段名以官方文档为准:
{
"answer": "billing",
"confidence": 0.94,
"rationale": "The message disputes a charge amount rather than reporting a bug."
}
- 对坏载荷零防御。 罕见,但解析要包 try,先重试一次再叫人:
try:
result = json.loads(resp.json()["choices"][0]["message"]["content"])
except (json.JSONDecodeError, KeyError):
result = ask_jev_with_retry(prompt) # 重试一次,然后告警
重试返回新的 fixture 形态答案;响应形态为示例数据(example fixture),正式字段名以官方文档为准。
confidence 异常
confidence 看起来不对时,按这个清单往下查:
- 所有答案都特别高。 问题多半没有真实边界——标准太宽,什么都是显然的 yes 或 no。收紧判定标准。
- 所有答案都特别低。 问题欠约束,或输入比问题假设的更嘈杂;按《问题设计》补边界和 few-shot 示例。
- 相似输入的 confidence 大幅摆动。 判分前先归一化输入(去 HTML、确定性地截断长文本),并确保每次调用用同一量表和同一选项列表。
- confidence 高但和你的标注对不上。 回查选项定义是否有重叠;互斥的定义方法在《Jev 问题设计与状态管理》里。
这些都不奏效时,取一条输入,用《第一次调用》教程的最小请求单独跑一遍——剩余问题多半是意外混进 prompt 的状态。product FAQ 打分那个 case 展示了把这套检查做成自动化环节的完整管线。
401 和 403 鉴权报错:逐步排查顺序
401 Unauthorized 和 403 Forbidden 看起来相似,但含义不同,混淆两者会浪费大量时间。按以下顺序逐步排查,不要跳步。
第一步——确认 Key 在正确的作用域里。 在和请求同一个 shell 进程里打印它。一个终端 session 里设置的环境变量,在另一个 shell 启动的子进程或没有显式传递的 Docker 容器里是看不到的。在运行脚本的那个 shell 里执行 echo $OPENROUTER_API_KEY,应该打印出非空值。
第二步——确认 Bearer 前缀。 Authorization 请求头必须是 Bearer sk-or-...,不能只有裸 Key。少一个空格,或者前缀写了两遍,都会报 401。
第三步——在 OpenRouter 查 Key 的权限范围。 部分 Key 被限定了只能访问特定模型或有消费上限。如果报的是 403(而不是 401),通常说明 Key 有效但没有权限访问你请求的模型。先登录 OpenRouter 检查 Key 的权限和剩余余额,再考虑重新生成。
第四步——排除时钟偏差。 部分代理层会拒绝时间戳偏差超过 5 分钟的令牌。如果你在一个被挂起再恢复的虚拟机或容器里运行,date 可能是错的。用 ntpdate 重新同步或重启时间守护进程。
第五步——轮换 Key 后单独验证。 如果以上都没解决,新建一个 Key,然后用第一次调用教程里的最小请求单独跑——不加任何封装和中间件,就是一个裸 curl。如果能跑通,问题在你的集成层,不是 Key。
持续报 403 且更换 Key 后依然如此,几乎总是消费上限或模型访问限制的问题,不是代码 bug。先查 OpenRouter 控制台的消费标签页。
429 限流:带抖动的退避重试
裸用 time.sleep(2 ** attempt) 能用,但当多个 worker 同时触发限流时反而会放大问题——所有 worker 在同一秒醒来,再次撞在一起。加入抖动(jitter)可以打破这种”惊群”模式。
标准做法是”全抖动”(full jitter):随机睡一个介于 0 和指数上限之间的时长。
import random, time
def backoff_sleep(attempt: int, base: float = 1.0, cap: float = 60.0) -> None:
ceiling = min(cap, base * (2 ** attempt))
time.sleep(random.uniform(0, ceiling))
在重试循环里的用法:
if resp.status_code == 429:
retry_after = int(resp.headers.get("Retry-After", 0))
wait = max(retry_after, random.uniform(0, min(60, 1.0 * (2 ** attempt))))
time.sleep(wait)
continue
Retry-After 响应头如果存在,就是权威值——始终遵守它,把抖动只用作下限,而不是替代它。
Key 级限流 vs. 账户级限流。 OpenRouter 两者都会执行。如果 429 的触发量你觉得莫名其妙,通常是 Key 触到了每分钟请求(RPM)上限,而账户余额其实没问题。响应体通常会说明是哪个限制被触发了——先解析它,再假设需要降低总吞吐量。
如果是持续的高并发场景,建议先看成本与延迟指南——上游做批处理或排队,往往能在不改重试逻辑的情况下彻底消除 429。
流式响应中断与半截断处理
流式响应("stream": true)不是原子操作。连接可能在中途断开,留给你一段无法解析的残缺 JSON 片段。以下三种故障模式最常见:
1. 连接提前关闭。 服务端在发送 [DONE] 哨兵之前就关闭了 SSE 流。你的客户端收到前 N 个 chunk 是有效 JSON,然后就是 EOF。判断方式:你的流数据累加器始终没有等到 [DONE]。修复:给读取侧(不只是连接侧)设置一个较大的 timeout,并把没有 [DONE] 就 EOF 的情况视为可重试错误。
import httpx
with httpx.stream("POST", url, headers=headers, json=body, timeout=120) as r:
buffer = ""
for line in r.iter_lines():
if line.startswith("data: "):
chunk = line[6:]
if chunk == "[DONE]":
break
buffer += json.loads(chunk)["choices"][0]["delta"].get("content", "")
# 如果循环在没有 [DONE] 的情况下退出,buffer 是不完整的——重试
2. Token 边界刚好切在 JSON 中间。 当模型流式输出结构化 JSON 时,一个 } 或 " 可能和它的开始符号落在不同 chunk 里。永远不要逐个 chunk 解析——先把完整 content 字符串累积起来,等到 [DONE] 后再整体 json.loads。
3. 网关在 30 秒处超时。 部分反向代理不管 SSE 帧格式,都会在 30 秒处切断连接。如果你在 nginx 或云负载均衡器后面运行,需要调大 proxy_read_timeout 或对应配置。症状是刚好在 30 秒处一个干净的 TCP close,不是 Jev 侧报错。
对于非流式调用的普通超时,用 timeout=(5, 90) 元组(连接超时、读取超时)比单个值更精准。
JSON 解析失败:三种不那么显眼的根因
除了上面提到的”忘了 json.loads”,还有三个更隐蔽的原因,能在修完第一个问题后继续造成解析失败。
根因 1——模型版本不兼容。 Jev api 的输出格式和模型 slug 绑定。如果你写死了 typesafe/jev-1.13,但有个代理层把 model 字段改写成了别的版本,响应可能返回的是纯文本而不是结构化 JSON。把响应里的 model 字段打印出来(它会回显实际使用的模型),和你发出去的值比对。
根因 2——代理内容改写。 部分 API 聚合商在返回给客户端前,会在 content 里注入免责声明,或者用一个外层 envelope 包裹。症状:json.loads 抛错,但 resp.text 显示有效 JSON 被埋在一个更大的字符串里。解决方法:绕过代理直接调用 OpenRouter 来隔离是哪一层在篡改数据。关于如何确认自己在官方渠道上,参见 API 渠道指南。
根因 3——提示格式污染。 如果你的 messages 数组里有一个已经序列化的 JSON 对象字符串(比如你先做了 json.dumps({"question": ...}),然后把结果嵌进了本身就带引号的用户消息里),模型可能会把双重编码的字符串原样回传。修复方法是把问题文本用纯文本发送,不要预先序列化成 JSON blob 塞进用户消息。
# 错误——把序列化的 JSON 嵌进了提示里
messages = [{"role": "user", "content": json.dumps({"question": text})}]
# 正确——纯文本问题,结构化输出是模型的事
messages = [{"role": "user", "content": text}]
快速诊断:如果 json.loads(content) 抛 JSONDecodeError,但 json.loads(json.loads(content)) 成功了,说明你在双重编码。
Context 过长导致截断
当 messages 数组的总 token 数超过模型上下文窗口时,API 会返回 400,错误信息里通常含 context_length_exceeded。但更隐蔽的失败是:代理悄悄截断了前面的消息以便装下请求——调用返回 200,模型却丢失了部分对话历史,开始给出牛头不对马嘴的答案。
怎么检测悄悄截断。 每次响应都检查 usage.prompt_tokens 字段,和你预计的 token 数比对。如果 prompt_tokens 明显小于你的估算,说明上游某一层在裁剪你的输入。
usage = resp.json().get("usage", {})
if usage.get("prompt_tokens", 0) < expected_tokens * 0.9:
raise RuntimeError(f"Prompt 被截断:实际 {usage['prompt_tokens']} tokens")
防止溢出。 对于多轮会话,在发送前先数 token,接近上限的 80% 时就把最老的 user/assistant 对踢掉。保留 system message 和最近的 user turn——它们携带最多信号。状态与问题设计指南里有对话状态管理的详细讲解。
对于把长文档送去批量分类的流水线,把输入切块、分别请求再汇总结果,比把一切塞进一个调用要稳得多。一个塞了 12,000 token 文本然后被悄悄截断的单次调用,远不如两次干净的 6,000 token 调用。
同一上下文混用不同模型族导致 400
在同一个对话线程里混用不同模型族,是一类看起来神秘的 400 Bad Request 的常见根源——因为请求体本身看起来完全没问题。场景是:你用某个模型开始了一次会话,存了 messages 数组,然后换了一个不同的模型继续调用,把同样的历史传了进去。
不同模型族对消息结构有不同的约束。system 消息放在非开头位置、content 字段为空、或者包含新模型不认识的上个模型版本产生的 tool_calls 字段——任何一种都可能触发一个描述含糊的 400。
排查清单:
- 看 400 响应体里的
error.message——它通常会指出有问题的字段。 - 把 messages 数组缩减到只剩最后一个 user turn 重发。如果成功了,问题在历史消息的结构里,不在当前提示。
- 在存储的消息里搜
"tool_calls"或"function_call"键——这些是模型版本专有的,换模型族时必须清掉。 - 检查有没有
content字段是null或空字符串——部分模型族会拒绝空的 assistant 消息,而另一些接受。
def sanitize_messages(messages: list) -> list:
clean = []
for m in messages:
if not m.get("content") and not m.get("tool_calls"):
continue # 丢掉空的 assistant 轮次
entry = {"role": m["role"], "content": m.get("content") or ""}
clean.append(entry)
return clean
如果确实需要在会话中途换模型,最稳妥的办法是把目前为止的对话摘要成一个 system message 概要,而不是把原始历史消息跨模型族传递。
本文适用版本 Jev 1.13。