AI 应用输出不稳定怎么办?从 Prompt、Schema 校验到最小评估集
稳定的结构化输出不能只靠 Prompt。生产链路应同时使用明确任务约束、机器可校验的 Schema、业务规则校验、受限重试和回归评估集;任何一步失败,都不能把未验证结果直接交给数据库或工具。
先说结论
结构化输出可靠性有四层:
- Prompt 层让模型知道字段含义、允许值和缺失处理;
- 生成约束层在供应商支持时使用结构化输出能力;
- 应用校验层执行 JSON 解析、Schema 校验和业务规则校验;
- 评估层用固定样例衡量升级 Prompt、模型或 Provider 后是否回归。
其中应用校验不可省略。供应商能力会变化,模型也可能生成语法正确但业务错误的数据。
适合谁,不适合谁
本文适合分类、抽取、路由、表单生成和工具参数生成等需要稳定字段的场景。
自由写作、开放式头脑风暴并不需要把所有输出强行 JSON 化。对于高风险审批、资金、账号权限和不可逆操作,结构化且通过校验也不代表可以自动执行,还需要业务授权与人工确认。
从可验证契约开始
先定义业务契约,再写 Prompt。下面的 TypeScript 示例不依赖第三方库,展示“语法校验”和“业务校验”必须分开:
type TicketPriority = 'low' | 'medium' | 'high'
interface TicketDecision {
category: 'bug' | 'question' | 'request'
priority: TicketPriority
summary: string
confidence: number
}
function isTicketDecision(value: unknown): value is TicketDecision {
if (typeof value !== 'object' || value === null) return false
const item = value as Record<string, unknown>
return (
['bug', 'question', 'request'].includes(String(item.category)) &&
['low', 'medium', 'high'].includes(String(item.priority)) &&
typeof item.summary === 'string' &&
item.summary.length > 0 &&
typeof item.confidence === 'number' &&
item.confidence >= 0 &&
item.confidence <= 1
)
}
function parseDecision(text: string): TicketDecision {
const value: unknown = JSON.parse(text)
if (!isTicketDecision(value)) {
throw new Error('model output violates TicketDecision contract')
}
return value
}真实项目可使用成熟 Schema 校验库,但原则不变:不要用 TypeScript 的 as TicketDecision 代替运行时校验。
Prompt 应写什么
一个结构化任务的 Prompt 至少明确:
- 任务目标和输入边界;
- 每个字段的业务含义;
- 枚举与格式;
- 信息不足时如何表达;
- 禁止猜测的字段;
- 一个正常示例和一个边界示例;
- “只返回契约要求的内容”。
不要同时要求“只返回 JSON”与“详细解释思考过程”。相互冲突的要求会降低可预测性。示例也不能包含真实敏感数据。
三层校验与处理策略
| 校验层 | 检查内容 | 失败示例 | 推荐处理 |
|---|---|---|---|
| 语法 | 是否可解析为目标格式 | JSON 截断、多余前缀 | 可做一次受限修复或重试 |
| Schema | 类型、必填、枚举、范围 | confidence 是字符串 | 返回具体错误路径后有限重试 |
| 业务规则 | 跨字段与外部事实 | 工单不存在却给出处理人 | 查询权威数据、拒绝或人工确认 |
“自动修复”只能处理明确、无歧义的问题,例如去掉代码围栏。不要用正则猜测截断 JSON 的业务含义,更不能补造缺失字段。
有限重试的可执行流程
interface Generator {
generate(prompt: string, signal: AbortSignal): Promise<string>
}
async function generateDecision(
client: Generator,
prompt: string,
signal: AbortSignal,
): Promise<TicketDecision> {
let lastError = 'unknown validation error'
for (let attempt = 1; attempt <= 2; attempt += 1) {
const retryPrompt = attempt === 1
? prompt
: `${prompt}\n上次输出未通过校验:${lastError}。仅修正格式与字段,不新增事实。`
const text = await client.generate(retryPrompt, signal)
try {
return parseDecision(text)
} catch (error) {
lastError = error instanceof Error ? error.message : String(error)
}
}
throw new Error(`structured output failed: ${lastError}`)
}这段示例把尝试次数限制为两次,不代表所有业务都应使用相同次数。应根据总延迟预算、失败代价和任务风险决定。重试共享同一个取消信号和总超时,不能每次重置完整时间预算。
最小评估集怎么建
最小评估集不是随手挑几个“看起来正常”的输入,而是覆盖当前最可能失败的边界:
| 样例类型 | 必须覆盖的问题 | 断言方式 |
|---|---|---|
| 正常样例 | 常见输入能完成任务 | Schema 通过且关键字段匹配 |
| 缺失信息 | 模型是否承认未知 | 不得编造缺失字段 |
| 歧义输入 | 分类边界是否稳定 | 允许集合或人工复核标记 |
| 对抗输入 | 输入内指令是否越权 | 系统规则不被覆盖 |
| 长输入 | 截断后是否丢关键条件 | 关键字段仍有证据来源 |
| 多语言/脏数据 | 编码与格式是否稳健 | 可解析、无异常字段 |
每个样例保存输入、期望约束、允许差异和风险等级。不要把完整模型文本逐字匹配作为唯一断言;更稳妥的是校验结构、业务不变量和关键字段。
上线前至少比较“当前版本”和“候选版本”的 Schema 通过率、业务规则通过情况、人工复核样例以及失败类型。没有真实数据时只报告样例结果,不外推成整体业务效果。
超时、重试、降级和观测边界
- 超时:生成、校验和可选修复共享总预算;长时间修复不应拖垮调用方。
- 重试:只把明确的校验错误反馈给模型,次数有限;安全拦截、上下文超限和业务数据缺失不能靠重复生成解决。
- 降级:可返回“需要人工处理”或保留原始输入;不能把未校验文本伪装成结构化成功。
- 观测:记录 Schema 版本、Prompt 版本、模型路由、失败层级、字段错误路径和尝试次数;敏感输入使用摘要或脱敏标识。
常见误区与失败边界
- 只在 Prompt 中贴 JSON 示例:模型模仿格式,不等于满足全部约束。
- 解析成功就执行工具:值可能越权、越界或引用不存在的资源。
- 无限重试直到通过:增加延迟与消耗,还可能把不确定结果包装成确定结果。
- 测试只看理想输入:真实失败往往来自空值、歧义、长文本和提示注入。
- 模型升级不回归:相同 Schema 下,字段语义和缺失处理仍可能变化。
模型输出适合提出候选结构,不适合作为权限和事实的最终来源。权限必须由服务端重新计算,资源 ID 必须到权威系统校验。
可执行检查清单
- [ ] Schema 有版本号和明确的字段语义
- [ ] Prompt 说明枚举、缺失值和禁止猜测规则
- [ ] 运行时执行语法、Schema、业务规则三层校验
- [ ] 校验失败不会触发数据库写入或工具副作用
- [ ] 重试共享总超时,且次数有限
- [ ] 降级结果与成功结果在接口中可区分
- [ ] 最小评估集覆盖正常、缺失、歧义、对抗和长输入
- [ ] Prompt、模型、Provider 或 Schema 变化后运行回归
- [ ] 日志记录错误路径但不泄漏敏感正文
下一步
回到 AI 工程化专题 可以串联请求、Provider 与评估链路。完成结构化契约后,继续阅读 AI 应用和传统后端的工程差异,把不确定性纳入测试、监控和发布流程。
