Appearance
课 1 · 质量控制
本课目标
掌握大模型应用的常见质量问题和应对方案:输出校验、重试策略、Fallback 机制、Human-in-the-Loop。课后你会知道怎么让 AI 应用足够可靠。
如果你是第一次做上线版,这一课先抓住最低配必修项:输出校验、超时、重试、基础 fallback。Prompt 质量控制和 Human-in-the-Loop 是在此基础上的增强项,不要求首版一口气做满。
前面做的 Demo 都跑在本地,出错了大不了重来。上线的产品不行——用户不会给你第二次机会。
这一课的核心问题:模型输出不可控,怎么让应用足够稳定?
首版上线先保三件事就够了:别乱答、别挂死、别误执行高风险操作。 下面的内容也会按这个优先级来理解。
先看一张“最低配可靠性防线”图:
最低配可靠性防线
模型输出
→
第 1 层结构化约束 / 输出校验
→
第 2 层超时控制 / 重试
→
第 3 层Fallback 降级
→
第 4 层Human-in-the-Loop
首版上线先保“别乱答、别挂死、别误执行高风险操作”。
先抓最低配上线版
如果你现在只想把第一版安全上线,建议先完成这些:
- 输出校验与结构化约束
- 超时控制与重试策略
- 基础 fallback
后面的 Prompt 质量控制和 Human-in-the-Loop 更像增强项:前者让回答更稳,后者让高风险动作更安全。
大模型应用的常见质量问题
| 问题 | 表现 | 频率 |
|---|---|---|
| 幻觉 | 模型编造事实、提供错误信息 | 高 |
| 格式错乱 | JSON 结构不对、缺少必需字段 | 中 |
| 工具调用失败 | 传错参数、调用不存在的工具 | 中 |
| 漏答 / 答非所问 | 没回答核心问题,跑偏了 | 中 |
| 拒绝回答 | 过度安全,该回答的也拒绝 | 低 |
| 延迟过高 | 响应时间太长,用户等不了 | 情况而异 |
和传统后端的区别:传统后端的 bug 是确定性的,修了就好。模型的问题是概率性的,不能"修",只能"防"。
输出校验与结构化约束
用 Zod Schema 约束输出
generateText + Output.object() + Zod Schema 是最有效的格式保障手段。模型输出不符合 Schema 时,SDK 会自动重试。
typescript
import { generateText, Output } from 'ai'
import { z } from 'zod'
const AnswerSchema = z.object({
answer: z.string().min(10).describe('回答内容,至少10个字'),
confidence: z.number().min(0).max(1).describe('置信度'),
sources: z.array(z.string()).describe('引用来源'),
})
const { output } = await generateText({
model,
output: Output.object({ schema: AnswerSchema }),
prompt: '什么是 RAG?',
})
// output 一定符合 AnswerSchema,否则会抛出错误输出后再验证
有些逻辑校验 Schema 做不了,需要在拿到输出后二次检查。
typescript
function validateAnswer(answer: z.infer<typeof AnswerSchema>): {
valid: boolean
reason?: string
} {
// 检查置信度过低
if (answer.confidence < 0.3) {
return { valid: false, reason: '置信度过低,可能是幻觉' }
}
// 检查来源为空
if (answer.sources.length === 0) {
return { valid: false, reason: '没有引用来源' }
}
return { valid: true }
}
const result = validateAnswer(output)
if (!result.valid) {
// 降级处理:返回"我不确定"或重试
}重试策略
模型调用会因为各种原因失败:网络超时、API 限流、输出格式错误。重试是最基本的容错手段。
指数退避重试
typescript
async function withRetry<T>(
fn: () => Promise<T>,
maxRetries = 3,
baseDelay = 1000
): Promise<T> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn()
} catch (error: any) {
if (attempt === maxRetries) throw error
// 判断是否值得重试
const isRetryable =
error.message?.includes('rate_limit') ||
error.message?.includes('timeout') ||
error.message?.includes('overloaded') ||
error.status === 429 ||
error.status === 503
if (!isRetryable) throw error // 非临时性错误,不重试
const delay = baseDelay * Math.pow(2, attempt) // 1s, 2s, 4s
console.log(`⏳ 第 ${attempt + 1} 次重试,等待 ${delay}ms...`)
await new Promise(r => setTimeout(r, delay))
}
}
throw new Error('不可达')
}
// 使用
const result = await withRetry(() =>
generateText({ model, prompt: '...' })
)超时控制
typescript
async function withTimeout<T>(
fn: () => Promise<T>,
timeoutMs: number
): Promise<T> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
return await fn()
} finally {
clearTimeout(timer)
}
}
// 限制单次调用不超过 30 秒
const result = await withTimeout(
() => generateText({
model,
prompt: '...',
abortSignal: controller.signal,
}),
30_000
)Fallback 机制
重试解决不了的问题(比如某个模型持续不可用),需要 Fallback。
Fallback 分层
请求进入 → 主模型调用
成功且通过质量检查直接返回结果
失败或质量检查不过切换 fallback 模型
fallback 可用返回备选模型结果
fallback 也不可用功能级降级:关键词搜索 / 返回原文
模型级 Fallback
typescript
async function generateWithFallback(prompt: string) {
const models = [
model, // 首选
modelFallback, // 备选:更快更便宜
]
for (const model of models) {
try {
return await withRetry(() =>
generateText({ model, prompt }),
2 // 每个模型最多重试 2 次
)
} catch (error) {
console.warn(`模型 ${model.modelId} 失败,切换下一个`)
}
}
// 所有模型都失败
return { text: '抱歉,服务暂时不可用,请稍后再试。' }
}在当前主线 basic/project 中,Fallback 通过完整的 FALLBACK_* 配置启用:
- 最少只需要配置
FALLBACK_MODEL - 没有单独配置的
FALLBACK_PROVIDER / FALLBACK_API_KEY / FALLBACK_BASE_URL会默认复用主模型对应的CHAT_*配置 - 如果主模型调用失败,或主模型输出通过不了质量检查,再切到 fallback 模型
本课独立 Demo 也支持同前缀配置,最少只需要 FALLBACK_MODEL;如果你还配置了 FALLBACK_API_KEY / FALLBACK_BASE_URL,会优先使用这些值,否则继续复用主模型配置。
功能级 Fallback
typescript
// 正常模式:RAG + LLM
// 降级模式:纯文本检索
async function answerQuestion(question: string) {
try {
// 正常:向量检索 + LLM 生成
const docs = await vectorSearch(question)
return await generateAnswer(question, docs)
} catch {
// 降级:关键词搜索 + 返回原文
const docs = await keywordSearch(question)
return {
text: `以下是相关文档(AI 摘要不可用):\n${docs.map(d => d.content).join('\n---\n')}`,
degraded: true,
}
}
}Prompt 质量控制技巧
很多质量问题的根源是 Prompt 写得不好。几个实用技巧:
1. 要求模型自我检查
typescript
const system = `你是技术顾问。回答用户问题时:
1. 先回答
2. 然后检查自己的回答是否有以下问题:
- 是否包含不确定的信息?如果有,标注"[未验证]"
- 是否遗漏了用户问题的某个方面?
3. 如果不确定答案,直接说"我不确定"`2. 限制回答范围
typescript
const system = `你只回答以下领域的问题:前端开发、TypeScript、React。
如果用户问的不在这些领域,回复"这个问题超出了我的知识范围"。
不要尝试回答你不擅长的问题。`3. 幻觉防控
typescript
// 结合 RAG 时,限制模型只从检索结果中回答
const system = `基于以下参考资料回答问题。
规则:
- 只使用参考资料中的信息
- 如果参考资料中没有相关内容,回复"根据现有资料无法回答"
- 不要编造参考资料中没有的信息
- 引用时标注来源编号
<references>
${docs.map((d, i) => `[${i + 1}] ${d.content}`).join('\n')}
</references>`Human-in-the-Loop
有些场景需要人工介入:高风险操作、低置信度结果、敏感内容。
这里要特别注意:不是所有工具都要审批。 Human-in-the-Loop 主要留给删除数据、发送消息、付款这类高风险动作,或者低置信度又不能自动降级的结果。
CLI 实现:在工具内部询问用户
在 Node.js CLI 程序里,在工具的 execute 内用 readline 向用户确认:
typescript
import * as readline from 'node:readline'
import { generateText, tool } from 'ai'
import { z } from 'zod'
async function confirm(prompt: string): Promise<boolean> {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
return new Promise(resolve => {
rl.question(`${prompt} (y/N) `, answer => {
rl.close()
resolve(answer.toLowerCase() === 'y')
})
})
}
const result = await generateText({
model,
prompt: '删除 doc-001 这个文档',
tools: {
// 高风险:删除前询问用户
deleteDocument: tool({
description: '从知识库删除文档',
inputSchema: z.object({ docId: z.string() }),
execute: async ({ docId }) => {
const ok = await confirm(`⚠️ 即将删除文档 ${docId},确认吗?`)
if (!ok) return '操作已取消'
// 实际删除逻辑
return `已删除文档 ${docId}`
},
}),
// 低风险:直接执行,不询问
searchDocs: tool({
description: '搜索文档',
inputSchema: z.object({ query: z.string() }),
execute: async ({ query }) => { /* 搜索逻辑 */ },
}),
},
})常见触发条件
| 操作 | 是否需要确认 |
|---|---|
| 读取 / 搜索数据 | 否 |
| 新增数据 | 视情况 |
| 修改 / 删除数据 | 是 |
| 发送消息 / 付款 | 是 |
Web 应用:用 AI SDK 原生支持
Web 项目中,AI SDK 提供了 needsApproval 属性,不需要手写确认逻辑:
typescript
deleteDocument: tool({
description: '从知识库删除文档',
inputSchema: z.object({ docId: z.string() }),
needsApproval: true, // 或 async (input) => 按条件决定
execute: async ({ docId }) => { /* 删除逻辑 */ },
})客户端用 addToolApprovalResponse 响应,SDK 自动处理暂停与恢复。详见 AI SDK 文档。
本课产物
- ✅ 了解大模型应用的常见质量问题
- ✅ 掌握 Zod Schema 输出校验 + 二次验证
- ✅ 掌握指数退避重试和超时控制
- ✅ 理解 Fallback 机制(模型级 + 功能级)
- ✅ 了解 Human-in-the-Loop 的设计方式
完整代码在 basic/examples/08-production/01-quality/index.ts。
试试看
bash
cd daqi-ai-agent
pnpm exec tsx basic/examples/08-production/01-quality/index.ts- 运行 Demo,先提一个正常问题,确认程序能返回结构化结果
- 连续提两个问题,观察输出中的
answer / confidence / reasoning / caveats以及最后的质量检查结果 - 输入
hitl,确认删除工具在真正执行前会先询问你 - 如果你配置了
FALLBACK_MODEL,可以把主模型暂时改成不可用模型名,观察程序是否切到 fallback;未配置则跳过这一步
面试追问
Q:如何减少大模型的幻觉?
多管齐下:1. 用 RAG 提供参考资料,并在 Prompt 中限制"只从参考资料回答"。2. 用低 temperature(0-0.3)降低随机性。3. 要求模型标注不确定信息,置信度低时拒绝回答。4. 输出后二次验证(事实核查、来源检查)。
Q:模型调用失败怎么处理?
分三层:1. 重试——指数退避,区分可重试错误(限流、超时)和不可重试错误。2. Fallback——首选模型不可用时切换备选模型。3. 降级——AI 功能完全不可用时,回退到传统逻辑(如关键词搜索替代语义搜索)。
Q:什么场景需要 Human-in-the-Loop?
高风险操作(删除数据、发送消息、付款)、低置信度结果、敏感内容处理。AI SDK 内置 needsApproval,在 Tool 定义时声明即可——可以是静态 true,也可以是按参数动态判断的函数。SDK 负责暂停执行、收集审批响应、再决定是否调用 execute,不需要手写审批流程。