Jev 常见报错排查与修复

更新于 适用版本 Jev 1.13

每个 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 RequestJSON 请求体格式错或缺必填字段本地先校验请求体能解析;核对 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 坏了”的报告,最后都发现是客户端这边的坑:

  1. 把 content 当字典用。 message content 是 JSON 字符串。resp.json()["choices"][0]["message"]["content"] 拿到的是字符串;访问 answer、confidence 之前先 json.loads。
  2. 把响应壳当答案。 answer 在解析后的 content 里,不在 HTTP 响应的顶层。HTTP 壳是 OpenAI 兼容的,判断载荷在里面——示例数据(example fixture),正式字段名以官方文档为准:
{
  "answer": "billing",
  "confidence": 0.94,
  "rationale": "The message disputes a charge amount rather than reporting a bug."
}
  1. 对坏载荷零防御。 罕见,但解析要包 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 看起来不对时,按这个清单往下查:

这些都不奏效时,取一条输入,用《第一次调用》教程的最小请求单独跑一遍——剩余问题多半是意外混进 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。

排查清单:

  1. 看 400 响应体里的 error.message——它通常会指出有问题的字段。
  2. 把 messages 数组缩减到只剩最后一个 user turn 重发。如果成功了,问题在历史消息的结构里,不在当前提示。
  3. 在存储的消息里搜 "tool_calls" 或 "function_call" 键——这些是模型版本专有的,换模型族时必须清掉。
  4. 检查有没有 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。

常见问题

+ 调用 Jev 一直报 404 怎么回事?

几乎都是模型 slug 写错或过期。到 OpenRouter 模型页核对当前 id——slug 会随版本变化——再用确切的值重发。

+ API 返回 200 但代码解析失败,为什么?

message content 是 JSON 字符串不是对象。先用 JSON 加载器解析,遇到罕见的坏载荷先重试一次再告警。

+ confidence 分数看起来不对——全高或全低,怎么办?

先检查问题有没有说清题型和边界;confidence 漂移通常是输入在漂,不是模型的锅。然后回看问题设计指南。

继续阅读