AI Agent不应信任LLM直接返回的结构——即使schema合法,字段值可能违背业务语义;建议在模型输出与业务代码间加入验证层。
我在审查过的几乎每一个 AI Agent 代码库中都见过这样一种失败模式:Agent 接收模型返回的内容后,直接信任其中的 JSON,然后调用 .result.items[0].id——结果在凌晨两点抛出 Cannot read properties of undefined,因为模型在某个边界情况下返回了 {"result": null}。
模型并没有在内容上产生幻觉。它在结构上产生了幻觉。
这种情况出乎意料地常见,而解决方案不是"用更好的 prompt"。解决方案是在原始模型输出和依赖于它的代码之间,架设一层验证层。
Claude 和 GPT-4 都支持结构化输出模式,能够约束模型输出符合给定 schema 的有效 JSON。这确实有用,你应该使用它。但这并不能完全解决问题,原因有二:
JSON 有效不等于语义有效。模型可以输出完全符合 schema 的有效 JSON,但内容仍然是错的。一个应该是 UUID 的字符串字段,可能包含一个在数据库查询中不存在的虚构标识符。一个标为 confidence_score 的整数字段可能是 847,而你的代码期望的是 0-1 的浮点数。Schema 约束的是类型,而不是语义。
并非所有 LLM 调用都使用结构化输出。如果你在做多步推理、思维链步骤、工具调用解析,或者处理不支持原生 JSON 模式的模型输出,你解析的是自由文本响应。你需要稳健地处理这种情况。
我现在构建的每一个 Agent 调用都经过三个阶段:
raw model output
↓
[PARSE] – 从文本中提取结构
↓
[VALIDATE] – 断言结构符合预期
↓
[CLASSIFY] – 对结果进行分类,以便调用方处理
以下是我实际使用的 TypeScript 实现:
import { z } from "zod";
// 1. 定义你期望的 schema
const AnalysisResultSchema = z.object({
sentiment: z.enum(["positive", "negative", "neutral"]),
confidence: z.number().min(0).max(1),
key_points: z.array(z.string()).min(1).max(10),
action_required: z.boolean(),
follow_up: z.string().optional(),
});
type AnalysisResult = z.infer<typeof AnalysisResultSchema>;
// 2. 解析-验证-分类包装器
type AgentOutput<T> =
| { ok: true; data: T }
| { ok: false; reason: "parse_failure" | "validation_failure" | "empty_response"; raw: string; error?: string };
function parseAgentOutput<T>(
raw: string,
schema: z.ZodSchema<T>
): AgentOutput<T> {
// 守卫:空或纯空白响应
if (!raw.trim()) {
return { ok: false, reason: "empty_response", raw };
}
// 从响应中提取 JSON —— 模型经常用正文或代码围栏包装它
const jsonMatch = raw.match(/```(?:json)?\s*([\s\S]*?)```/) ||
raw.match(/(\{[\s\S]*\}|\[[\s\S]*\])/);
const jsonString = jsonMatch ? jsonMatch[1] ?? jsonMatch[0] : raw.trim();
let parsed: unknown;
try {
parsed = JSON.parse(jsonString);
} catch (err) {
return {
ok: false,
reason: "parse_failure",
raw,
error: err instanceof Error ? err.message : "JSON.parse failed",
};
}
const result = schema.safeParse(parsed);
if (!result.success) {
return {
ok: false,
reason: "validation_failure",
raw,
error: result.error.errors.map(e => `${e.path.join(".")}: ${e.message}`).join("; "),
};
}
return { ok: true, data: result.data };
}
AgentOutput<T> 这个可辨识联合类型,强制调用方同时处理成功路径和失败路径。你无法在未先检查 output.ok 的情况下意外访问 output.data。
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
async function analyzeCustomerFeedback(
feedback: string
): Promise<AgentOutput<AnalysisResult>> {
const response = await client.messages.create({
model: "claude-sonnet-4-5",
max_tokens: 512,
system: `You analyze customer feedback. Always respond with JSON matching this schema exactly:
{
"sentiment": "positive" | "negative" | "neutral",
"confidence": number between 0 and 1,
"key_points": array of strings (1-10 items),
"action_required": boolean,
"follow_up": optional string
}
No prose. No markdown. Just the JSON object.`,
messages: [{ role: "user", content: feedback }],
});
const rawText = response.content
.filter((b): b is Anthropic.TextBlock => b.type === "text")
.map(b => b.text)
.join("");
return parseAgentOutput(rawText, AnalysisResultSchema);
}
// 调用方显式处理两种结果
const result = await analyzeCustomerFeedback(userFeedback);
if (!result.ok) {
// 记录失败,包含完整上下文以便调试
console.error("Agent output invalid", {
reason: result.reason,
error: result.error,
raw: result.raw.slice(0, 500), // 不记录过大的 payload
});
// 决定如何处理:重试、降级、呈现给用户等
return handleValidationFailure(result.reason);
}
// TypeScript 知道 result.data 在这里是 AnalysisResult
const { sentiment, confidence, key_points } = result.data;
并非所有验证失败都是永久性的。有时候模型第一次生成了格式错误的 JSON,但重试就能得到正确结果。关键在于区分哪些失败值得重试。
async function analyzeWithRetry(
feedback: string,
maxAttempts = 3
): Promise<AnalysisResult> {
let lastError = "";
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const result = await analyzeCustomerFeedback(feedback);
if (result.ok) return result.data;
lastError = result.error ?? result.reason;
// 空响应不重试 —— 有其他问题
if (result.reason === "empty_response") break;
// 验证失败时,将错误作为反馈给模型
if (attempt < maxAttempts && result.reason === "validation_failure") {
// 可以将错误传入下一次 prompt:"你的上一次响应验证失败:{lastError}。请重试。"
console.warn(`Attempt ${attempt} failed validation: ${lastError}`);
continue;
}
}
throw new Error(`Failed after ${maxAttempts} attempts. Last error: ${lastError}`);
}
在重试 prompt 中将验证错误反馈给模型这个模式特别有效。你不是在盲目重试,而是告诉模型哪里出了问题。以我的经验,当第一次尝试出现验证失败时,大约有 80% 的情况能在第二次尝试时得到有效输出。
当验证在生产环境失败时,你需要足够的信息来理解和修复问题——但又不能记录过多个人信息或消耗过多存储成本。
// 好:结构化的、可查询的、安全的
console.error(JSON.stringify({
event: "agent_validation_failure",
reason: result.reason,
error_path: result.error, // 哪个字段失败了
response_length: result.raw.length,
response_prefix: result.raw.slice(0, 100), // 足以看出模式
model: "claude-sonnet-4-5",
timestamp: new Date().toISOString(),
}));
经过一周的生产日志后,你就会看到规律。也许模型在处理某些类别的输入时总是遗漏 confidence 字段。也许当输入包含换行符时它把数组返回成了字符串。这些规律告诉你应该在哪里加强 prompt 或添加额外的强制转换逻辑。
如果觉得 Zod 大材小用,这里有一个最小化版本,仍然能捕获最常见的失败:
import json
from typing import TypedDict
class AnalysisResult(TypedDict):
sentiment: str
confidence: float
action_required: bool
REQUIRED_KEYS = {"sentiment", "confidence", "action_required"}
VALID_SENTIMENTS = {"positive", "negative", "neutral"}
def parse_analysis(raw: str) -> AnalysisResult | None:
# 去除代码围栏(如果存在)
text = raw.strip()
if text.startswith("```"):
text = text.split("```")[1]
if text.startswith("json"):
text = text[4:]
try:
data = json.loads(text.strip())
except json.JSONDecodeError:
return None
# 检查必需字段
if not REQUIRED_KEYS.issubset(data.keys()):
return None
# 检查语义约束
if data["sentiment"] not in VALID_SENTIMENTS:
return None
if not (0 <= float(data["confidence"]) <= 1):
return None
return data
虽然不像 Zod 那样可组合,但它能捕获常见的失败模式:缺失字段、枚举值错误、范围外的数字。
LLM 是概率性的。它们不能保证结构化输出一定有效——哪怕你客客气气地请求。生产级 Agent 需要一层确定性的逻辑,在任何代码依赖输出之前,将每个输出分类为有效或无效。先构建这层逻辑,记录它的失败,让失败数据告诉你 prompt 需要在哪里改进。
验证层不会拖慢你——它让你的 Agent 可调试。没有它,你就是在盲目飞行。
我在免费《可靠 Agent 现场指南》中涵盖了验证模式、重试逻辑和生产可靠性相关内容:penloomstudio.com/field-guide.html