Agent 输出下游解析不了

Agent 把 JSON 包进 Markdown 代码块、或在前后加了一句废话,下游解析直接崩。用原生结构化输出彻底修好(2026 年 6 月)。

你的 LangGraph 流水线里有个分析 Agent,本应输出形如 {"issues": [...], "severity": "high"} 的 JSON 对象。下游的路由 Agent 调用 json.loads(output),结果崩在 json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)。实际上这个分析 Agent 返回的是:

Here's my analysis:

```json
\{"issues": ["missing null check"], "severity": "high"\}
```

Let me know if you need more detail.

JSON 是有的,只是被埋在 Markdown 代码块里,前后还裹着一层自然语言。这是多 Agent 流水线里最常见的输出格式故障,而且会滚雪球:每一次下游解析失败,要么直接让整条流程崩掉,要么把垃圾数据静默地往下游继续传。

TL;DR — 最快的可靠修法

别再在 prompt 里写「请输出 JSON」了,改用厂商的原生结构化输出——它在 token 级别把生成约束到你的 schema 上,不符合 schema 的文本根本生成不出来。截至 2026 年 6 月,两大厂商都已支持:

  • OpenAIclient.chat.completions.parse(...)(已 GA,不再需要 .beta 前缀),把 Pydantic model 传给 response_format
  • Anthropic:原生结构化输出在 2025 年 11 月 14 日开放公测后已转为 GA。用 client.messages.parse(...) 配 Pydantic model,或用原始的 output_config={"format": {"type": "json_schema", ...}} 参数。旧的「强制调用一个 tool」的变通做法仍然能用,但已不是推荐路径。

如果今天来不及改调用点,先用 Step 2 的 extract_json 兜底,把代码块/前后缀里的 JSON 抢救出来,再排期做真正的修复。

你属于哪种情况?

你观察到的现象最可能的根因跳转
每隔几次就报 Expecting value: line 1 column 1JSON 前后有文字/代码块根因 1、2
用了几个月好好的,某一天起开始失败模型版本被自动升级根因 3
只有大结果集失败,输出在 token 中途被截断max_tokens 截断根因 4
只在对话靠后的轮次失败上下文漂移到聊天语气根因 5
json.loads 成功,但下游 KeyError/Noneschema 漂移,解析端和 Agent 不一致根因 6

常见原因

1. System prompt 要了 JSON,却没禁止输出散文

prompt 只写了「请输出 JSON」,没写「只输出 JSON,不要任何其他文字」。LLM 天然倾向对话式表达:会加开场白(「以下是结果:」)、收尾语(「如有疑问请联系我」),即使你要的是裸 JSON 也会自作主张地套上代码块。

怎么判断:把最近 10 条 Agent 输出在解析前的原始字符串打出来,数一数有几条在第一个 { 之前、或最后一个 } 之后还有字符。如果 10 条里超过 2 条有前后缀文字,说明 prompt 不够严——而光靠 prompt 永远到不了零失败。

2. 完全没做 schema 强制——只靠 prompt

整条流水线完全依赖 prompt 指令来产出结构化输出,没有 schema 校验、没有 Pydantic model、也没有结构化输出 API 调用。模型的合规是概率性的,而非强制的。这往往是其他几个根因背后的总根源。

怎么判断:检查 Agent 调用是否传了 response_format(OpenAI)或 output_config/output_format(Anthropic)。如果只是用一段普通字符串 prompt 调用、再把响应当字符串读,那就是没有任何强制。

3. 模型版本变更打破了原本稳定的格式

你的流水线在某个固定快照上稳定跑了几个月。一次自动模型升级后——比如从一个已废弃的 GPT-4 时代快照切到了 GPT-5.5,或者 Claude 的一个小版本更新——同样的 prompt 现在偶尔就吐出带代码块的 JSON。不同 checkpoint 的格式习惯不一样,「以前没问题」对一个新 checkpoint 没有任何保证。

怎么判断:查清格式失败是从什么时候开始的。如果时间点和模型版本变更或厂商基础设施更新对得上,那就是新模型的格式回退。生产环境永远要 pin 一个带日期的快照,让升级是「有意为之」而不是「悄悄发生」。

4. 输出太长,被截断成残缺 JSON

任务要求 Agent 返回一个很大的 JSON 数组,输出在数组中途撞上了 max_tokens 上限。结果是前半段合法、后面被切掉:["item1", "item2", "ite——json.loads() 自然拒收。

怎么判断:看解析失败是否和大结果集相关。如果失败输出的 token 数刚好顶在你的 max_tokens 天花板上,那就是截断。直接看 response.stop_reason == "max_tokens"(Anthropic)或 finish_reason == "length"(OpenAI),这个标志就是铁证。

5. 多轮对话里累积了非 JSON 的轮次

在多轮会话里,Agent 在第 3 轮还能输出合法 JSON,到第 8 轮随着对话变长就开始加评论。模型在向上下文窗口里早先那些轮次的对话语气「靠拢」。

怎么判断:记录每次解析失败发生在第几轮。如果失败集中在靠后的轮次,就是上下文漂移导致的格式回退。在每条用户消息里都复述一遍格式契约(而不只在 system prompt 里说一次),实测能明显改善。

6. 解析端假设的格式,和 Agent 改过的格式不一致

schema 演进了:Agent 现在返回 {"result": {"issues": [...]}}(嵌套),但解析代码还在读 data["issues"](扁平)。不报 JSON 错误,只是 KeyError,或者本该是 list 的地方静默变成 None

怎么判断:把解析代码里假设的 schema,和 Agent 今天实际返回的 schema 对比一下。两者漂移就是格式不匹配——哪怕 JSON 本身完全合法。

最短修复路径

Step 1:用原生结构化输出,别再靠 prompt 控格式

OpenAI——截至 2026 年 6 月 parse() 已 GA,直接用即可(不要再写 client.beta...):

from pydantic import BaseModel

class AnalysisResult(BaseModel):
    issues: list[str]
    severity: str
    confidence: float

completion = client.chat.completions.parse(
    model="gpt-5.5",
    messages=messages,
    response_format=AnalysisResult,
)

msg = completion.choices[0].message
if msg.refusal:                 # 安全拒答会出现在这里,而不是变成 JSON
    raise OutputFormatError(msg.refusal)
result = msg.parsed             # 带类型的 AnalysisResult 对象

更新的 Responses API 用 client.responses.parse(..., text_format=AnalysisResult),结果对象通过 response.output_parsed 取——按你代码库已有的那套 API 选一种即可。

注意 OpenAI 的 strict 模式规则:每个字段都必须列进 required,且 schema 必须设 additionalProperties: false。要表示一个真正可选的字段,把它的类型设成可空(比如 confidence: float | None),而不是直接省略。

Anthropic——原生结构化输出(2026 年已 GA;公测期的 beta header structured-outputs-2025-11-13 现已不再需要):

from anthropic import Anthropic
from pydantic import BaseModel

class AnalysisResult(BaseModel):
    issues: list[str]
    severity: str

client = Anthropic()
response = client.messages.parse(
    model="claude-sonnet-4-6",
    max_tokens=1024,
    messages=messages,
    output_format=AnalysisResult,
)
result = response.parsed_output   # 带类型的 AnalysisResult 对象

如果你的 SDK 版本较旧,就传原始参数,再读 response.content[0].text(一个保证符合 schema 的 JSON 字符串):

response = client.messages.create(
    model="claude-sonnet-4-6",
    max_tokens=1024,
    messages=messages,
    output_config={
        "format": {
            "type": "json_schema",
            "schema": {
                "type": "object",
                "properties": {
                    "issues": {"type": "array", "items": {"type": "string"}},
                    "severity": {"type": "string", "enum": ["low", "medium", "high"]},
                },
                "required": ["issues", "severity"],
                "additionalProperties": False,
            },
        }
    },
)

Anthropic 会把你的 schema 编译成一套语法(grammar),在生成时逐 token 约束,所以模型根本生成不出违反 schema 的文本。两个实用提醒:对某个新 schema 的首次请求要付一次性的语法编译延迟,之后编译好的语法会从最后一次使用起缓存 24 小时;并且每个对象上 additionalProperties: false 是必填的。

tool-calling(用 tool_choice={"type": "tool", "name": "submit_analysis"} 强制单个 tool、再读 response.content[0].input)依然能用,如果你已经接好了那套,继续用没问题;但现在更干净的默认选择是原生结构化输出。

Step 2:写一个 JSON 提取兜底函数

只在拿不到原生结构化输出的场景用它(第三方网关、本地模型)。它是针对代码块/前后缀包裹的创可贴,治不了截断和 schema 漂移。

import re, json

def extract_json(text: str) -> dict:
    # 1. 先试直接解析
    try:
        return json.loads(text.strip())
    except json.JSONDecodeError:
        pass

    # 2. 剥掉 Markdown 代码块(json / JSON / 无语言标记)
    fenced = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
    if fenced:
        try:
            return json.loads(fenced.group(1))
        except json.JSONDecodeError:
            pass

    # 3. 退而求其次:取最外层的一对大括号
    start, end = text.find("{"), text.rfind("}")
    if start != -1 and end != -1 and end > start:
        try:
            return json.loads(text[start:end + 1])
        except json.JSONDecodeError:
            pass

    raise ValueError(f"无法从 Agent 输出中提取 JSON:{text[:200]!r}")

Step 3:用明确的负面约束加固 system prompt

在拿不到原生结构化输出的路径上,把 prompt 收紧。负面约束加上「首尾字符」规则,效果远胜含糊的「请输出 JSON」:

Respond with ONLY a valid JSON object. No markdown. No code fences. No preamble.
No postamble. No explanation. The first character of your response must be the
opening brace, and the last character must be the closing brace. If you cannot
produce valid JSON, respond with:
{"error": "unable to analyze", "reason": "<one sentence>"}

Step 4:解析后再做 schema 校验

即使是保证合法的 JSON,重构之后形状也可能不对。在任何下游消费之前先校验:

from pydantic import BaseModel, ValidationError

class AnalysisResult(BaseModel):
    issues: list[str]
    severity: str

def parse_and_validate(raw: str) -> AnalysisResult:
    data = extract_json(raw)
    try:
        return AnalysisResult(**data)
    except ValidationError as e:
        raise OutputFormatError(f"Agent 输出未通过 schema 校验:{e}") from e

schema 校验能抓住字段级问题(缺字段、类型错、枚举越界)——这些是裸 json.loads() 会高高兴兴放行的。

Step 5:把 max_tokens 调到合适大小,防截断

# 把 max_tokens 调到与预期输出匹配。一个含约 20 条 issue 的 JSON 对象
# 平均约 500 token,所以 4096 太浪费,而过低的天花板会导致截断。
response = client.messages.create(
    model="claude-sonnet-4-6",
    max_tokens=1024,
    messages=messages,
)

# 如果结果集可能很大,就分页,而不是把数组撑大:
# Prompt: "Return at most 10 issues per call. Include a 'has_more' boolean."

如果还在截断,先查 stop_reason/finish_reason(见根因 4),别一上来就当成格式 bug。

Step 6:写一个格式回归测试

def test_agent_output_format():
    for inp in load_fixture("agent_format_test_inputs.json"):
        raw = run_agent(inp)
        result = parse_and_validate(raw)
        assert result.issues is not None
        assert result.severity in ("low", "medium", "high")

在 CI 里对着生产环境 pin 的那个模型快照跑这个测试。一旦模型版本升级导致格式回退,CI 会在用户之前先发现。

如何确认已经修好

  1. 把最近 100 条失败的输入用新代码路径重放一遍,解析成功率应该达到或接近 100%。
  2. 确认调用确实用上了结构化输出——把请求打日志,核对 response_format/output_config 在不在。(一个常见的假修复是「prompt 收紧了」,但没约束的调用本身一点没变。)
  3. 在 CI 里对着 pin 的快照跑 Step 6 的回归测试。
  4. 盯生产环境的解析失败率指标 24 小时,应该稳稳压在 1% 以下。

预防建议

  • 用厂商的原生结构化输出(OpenAI 的 response_formatparse(),Anthropic 的 output_config/messages.parse()),别靠 prompt 指令。
  • 拿不到结构化输出的地方,用明确负面约束加固 prompt(不要散文、不要代码块、首尾字符是大括号)。
  • 解析后立刻用 Pydantic schema 校验,再做任何下游消费。
  • max_tokens 调到预期输出大小,而不是模型上限——截断导致的解析失败很好防。
  • 生产环境 pin 一个带日期的模型快照,让版本升级是有意为之;并写格式回归测试,在 CI 里对着这个快照跑。
  • 给输出 schema 显式做版本管理;schema 变更时,在同一个 commit 里同时改 Agent 的 schema 和解析代码。
  • 对每一条校验失败的输出,记录解析前的原始字符串——诊断格式问题需要看到确切的字符。
  • 对生产环境的解析失败率做监控,超过 1% 时告警。

常见问答 (FAQ)

Q: JSON mode、原生结构化输出、tool-calling,我该用哪个? A: 原生结构化输出(OpenAI 的 response_format 配 JSON schema/Pydantic model;Anthropic 的 output_config/messages.parse())最强,因为生成在 token 级别被约束到你的 schema 上,不合规的输出根本产不出来。普通的「JSON 模式」(response_format={"type": "json_object"})只保证语法合法的 JSON,不保证是你要的那个形状。强制 tool-calling 仍然好用,已经在用就不必改,但它不再是结构化数据的推荐默认选项。

Q: 不改 Agent 调用能修好吗? A: Step 2 的 extract_json 兜底能在生产里救回大部分代码块/前后缀包裹的情况。但它是创可贴:治不了截断的 JSON,也治不了 schema 漂移。要根治就把调用切到原生结构化输出。

Q: 我代码一行没改,怎么突然就开始失败了? A: 几乎可以肯定是模型版本被自动升级了(根因 3)。如果你用的是浮动模型别名,厂商把你切到了一个格式习惯不同的新 checkpoint。pin 一个带日期的快照,再加一个 CI 格式测试,下次升级就能在上生产前被抓住。

Q: 第一次结构化输出请求很慢,是不是出问题了? A: 不是。Anthropic 对每个新 schema 在首次使用时会编译成语法,这会带来一次性延迟;编译好的语法之后会从最后一次使用起缓存 24 小时,后续调用就很快。尽量复用同一个 schema,而不是每次请求都重新生成;也别没事改字段结构(只改 description 文本不会让缓存失效)。

Q: 需要解析的流式(streaming)响应怎么处理? A: 把整个流缓冲完再解析——部分流会产出残缺 JSON。结构化输出支持流式,但你仍然要把所有事件攒齐再反序列化。如果需要实时进度,就发显式的进度事件({"type": "progress", "pct": 50}),而不是发部分结果对象。

相关阅读

标签: #AI 编程 #Agents #排查