知识点思维导图
35 个知识节点
生产工程(09) - 生产问题排查清单
读完后,你应能完成以下任务:
- 绘制“生产工程(09) - 生产问题排查清单 / 最常见的几类故障和它们的"指纹"”的关键对象与数据流,解释“关键洞察:"超时"和"空回答"和"召回失败"是三个不同的故障,指纹不同、改的地方也不同。”,并用源码位置、日志或 Trace 标注证据。
- 为“生产工程(09) - 生产问题排查清单 / 看延迟别只看平均值”设计正常与异常输入,验证“P90:大多数用户的慢请求边界。”,输出首个偏差位置与回归测试结果。
- 实现“生产工程(09) - 生产问题排查清单 / 排查决策树:逐段定位,别跳步”的最小代码或配置,检验“第 3、4 步是最容易出错的地方:都是"没给出有效答案",但 hit_count == 0 是检索的锅,hit_count > 0 但 answer 为空 是生成的锅。”,输出命令、结果与 Diff,并说明不适用边界。
一、生产问题排查清单的真实应用场景
周一早上,群里炸了:用户反馈知识库助手"一问就转圈""答了等于没答""明明有的文档说查不到"。三个现象,你得快速判断各是什么问题、改哪里。
新手的反应是打开代码一行行看、凭感觉猜。老手的反应是:调出这几次请求的观测记录,按字段逐段定位。 超时看耗时分布在哪一步,空回答看检索命中没有,召回失败看 query 和 chunk。有结构化日志(可观测性(05)《AI 应用日志与可观测性》)打底,排查就是走一遍决策树,不是玄学。
这一篇给你这套决策树。
二、最常见的几类故障和它们的"指纹"
每类故障在观测记录里都有特定的"指纹"——哪个字段异常,基本就锁定了根因:
| 故障现象 | 指纹(哪个字段异常) | 根因方向 |
|---|---|---|
| 超时转圈 | model_ms 或 retrieve_ms 很大 |
模型/检索慢,定位是哪一步 |
| 拒答"资料不足" | hit_count == 0 |
检索没命中(召回失败) |
| 空回答 | answer 空但 hit_count > 0 |
检索好的,生成端出问题 |
| 答非所问/编造 | hit_count > 0 但答案与资料无关 |
幻觉,prompt 或 grounding 问题 |
| 报错 500 | error 非空 |
链路异常,看错误日志 |
关键洞察:"超时"和"空回答"和"召回失败"是三个不同的故障,指纹不同、改的地方也不同。 把它们混为一谈"AI 不好用",就永远修不对。
三、看延迟别只看平均值
线上排查要看 P50/P90/P99:
- P50:一半用户的体感,代表常规体验。
- P90:大多数用户的慢请求边界。
- P99:最差 1% 请求,常暴露模型排队、向量库索引、外部工具慢调用。
如果平均耗时没变但 P99 飙升,说明不是整体慢,而是少数请求卡住。排查要看 trace:是 retrieve、rerank、generate、tool 还是 judge 阶段拖慢。
四、排查决策树:逐段定位,别跳步
诊断要有固定顺序,从"是否崩了"到"慢在哪"再到"质量问题在检索还是生成":
第 3、4 步是最容易出错的地方:都是"没给出有效答案",但 hit_count == 0 是检索的锅,hit_count > 0 但 answer 为空 是生成的锅。改错地方就是因为没区分这两个。
五、几类故障的具体排查方向
**召回失败(hit_count=0)**最常见,按这个顺序查:
- query 太口语化?→ 加 query 改写(Agent(19)《LangGraph 入门》 LangGraph 的循环就是干这个)
- chunk 切太大/太小?→ 调切分粒度
- 相似度阈值太高?→ 适当放宽
- 知识库真有这资料吗?→ 别排查半天发现压根没入库
超时按耗时分布定位:是检索慢(向量库索引、并发)还是模型慢(服务负载、网络、prompt 太长)。模型慢的缓解手段是流式返回(先吐字降低体感延迟)、超时重试、降级到小模型。
**幻觉(答非所问)**最隐蔽,因为指标看着都正常。要靠 prompt 强约束"只基于资料回答"、回答附引用来源让人能核对、以及评测集兜底(RAG(27)《RAG 评测与调优》)。
六、工程上真正会踩的坑
- 没有观测数据就开始猜。没记
hit_count、model_ms,排查只能靠复现和猜。排查能力的上限是可观测性的上限——这也是为什么可观测性(05)《AI 应用日志与可观测性》排在这之前。 - 把所有问题归为"模型不行"。召回失败是检索的锅、空回答是 prompt 的锅,甩给模型既修不对也甩锅。先定位是哪一段。
- 线上不能复现就放弃。AI 输出有随机性,但请求记录是确定的。抓住那次请求的
request_id,调出它的完整 trace,比反复复现高效得多。 - 修了不验证。改了 chunk 切分或阈值,凭感觉觉得好了。要用评测集(RAG(27)《RAG 评测与调优》)跑一遍命中率,确认是真变好不是错觉。
七、一句话面试答法
线上 AI 应用出问题你怎么排查? 我不靠猜,靠观测记录逐段定位。先看有没有抛异常,再看各阶段耗时定位慢在检索还是模型,再看检索命中数和回答区分质量问题。关键是同样"答不出"根因可能完全不同:检索命中 0 条是召回失败,要查 query 改写、chunk 切分、相似度阈值;检索命中了但回答为空是生成端问题,要查 prompt、安全拦截、max_tokens。这套排查能落地的前提是结构化日志记了 requestId、耗时、命中数这些字段,可观测性是排查的地基。改完我会用评测集验证是真的修好了。
八、动手实践:42 生产问题排查清单
一个排查脚本:对一次请求的观测记录(耗时、命中数、回答、异常)走诊断决策树,模拟「正常 / 模型超时 / 召回失败 / 空回答」四种线上故障并给出排查结论。
8.1 在线运行
零依赖,纯标准库。
8.2 预期输出
=== req-001 (检索50ms 模型800ms 命中2) ===
[正常] 各项指标在阈值内,请求健康
=== req-002 (检索60ms 模型5200ms 命中2) ===
[超时] 模型调用 5200ms 超过阈值 3000ms -> 检查模型服务负载/网络,考虑流式返回、超时重试、降级到小模型
=== req-003 (检索55ms 模型700ms 命中0) ===
[召回失败] 检索命中 0 条 -> 检查 query 是否口语化(要不要改写)、chunk 切分是否合理、相似度阈值是否过高、知识库是否真有这资料
=== req-004 (检索40ms 模型900ms 命中3) ===
[空回答] 检索命中但回答为空 -> 问题在生成端:检查 prompt 模板、模型是否被安全策略拦截、max_tokens 是否设成了 0
同样是「没给出有效回答」,req-003 和 req-004 的根因完全不同:一个是检索没命中(问题在检索端),一个是检索命中了但生成端出问题。靠观测字段区分根因,这就是排查的核心。
8.3 代码 ↔ 概念对应
| 概念 | 在 main.py 哪里 |
|---|---|
| 一次请求的可观测记录 | RequestTrace(对应可观测性(05)《AI 应用日志与可观测性》的结构化日志) |
| 诊断决策树 | diagnose |
| 超时诊断(区分检索慢/模型慢) | diagnose 里 model_ms / retrieve_ms 判断 |
| 召回失败诊断 | hit_count == 0 分支 |
| 空回答 vs 召回失败的区分 | not answer and hit_count > 0 分支 |
| 异常优先定位 | if trace.error 分支 |
8.4 排查的核心思路
不是「看到报错猜原因」,而是「按一次请求的结构化记录,逐段定位是哪一步出的问题」。诊断顺序:
- 先看有没有抛异常(最高优先级,直接定位)
- 再看各阶段耗时,定位慢在检索还是模型
- 再看命中数和回答,区分是检索端问题还是生成端问题
这套逻辑能落地的前提,是可观测性(05)《AI 应用日志与可观测性》讲的结构化日志——没有 hit_count、model_ms 这些字段,根本没法这样逐段定位。可观测性是排查的地基。
8.5 动手改
- 加一个
token字段和「token 异常飙高」的诊断分支(可能是 prompt 注入或死循环)。 - 把阈值
TIMEOUT_MS调低,观察 req-001 也被判为超时。 - 加一种新故障:检索命中但答案与资料无关(幻觉),思考要补什么观测字段才能诊断它。
8.6 可运行源码:生产问题排查清单
main.py
九、总结
- 最常见的几类故障和它们的"指纹":关键洞察:"超时"和"空回答"和"召回失败"是三个不同的故障,指纹不同、改的地方也不同。
- 看延迟别只看平均值:P90:大多数用户的慢请求边界。
- 排查决策树:逐段定位,别跳步:第 3、4 步是最容易出错的地方:都是"没给出有效答案",但 hit_count == 0 是检索的锅,hit_count > 0 但 answer 为空 是生成的锅。
- 几类故障的具体排查方向:query 太口语化?→ 加 query 改写(Agent(19)《LangGraph 入门》 LangGraph 的循环就是干这个) -> chunk 切太大/太小? -> 相似度阈值太高?→ 适当放宽 -> 知识库真有这资料吗?→ 别排查半天发现压根没入库
- 工程上真正会踩的坑:排查能力的上限是可观测性的上限——这也是为什么可观测性(05)《AI 应用日志与可观测性》排在这之前。
- 一句话面试答法:关键是同样"答不出"根因可能完全不同:检索命中 0 条是召回失败,要查 query 改写、chunk 切分、相似度阈值;
9.1 可运行实验:模型路由、限流、重试与熔断
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>AA-09 在线实验</title>
<style>
:root{color-scheme:dark;font-family:Inter,system-ui,sans-serif}*{box-sizing:border-box}body{margin:0;background:#0f1211;color:#e7ece9;font-size:13px}.shell{padding:16px}.top{display:flex;justify-content:space-between;gap:16px;margin-bottom:14px}h1{margin:3px 0;font-size:18px}.id,.value{color:#68e0b5;font-family:ui-monospace,monospace}.summary{margin:4px 0;color:#a5afa9}.run{border:0;border-radius:6px;background:#68e0b5;color:#07110d;padding:8px 14px;font-weight:700}.grid{display:grid;grid-template-columns:minmax(220px,.8fr) minmax(0,1.8fr);gap:12px}.panel{border:1px solid #29322e;background:#141817;padding:12px}.control{display:grid;gap:5px;margin-bottom:11px}.head{display:flex;justify-content:space-between;gap:8px}select,input{width:100%;accent-color:#68e0b5;background:#0d100f;color:#e7ece9}.toggle{display:flex;justify-content:space-between;border-top:1px solid #29322e;padding-top:9px}.toggle input{width:18px}.metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:7px}.metric{border:1px solid #29322e;padding:8px}.metric b{display:block;color:#68e0b5;font-size:16px}.stages{display:flex;gap:6px;overflow:auto;margin:10px 0}.stage{border:1px solid #8a6230;padding:7px;min-width:90px}.stage.ok{border-color:#367a61}.stage.fail{border-color:#8b4545}table{width:100%;border-collapse:collapse}td{border-top:1px solid #29322e;padding:7px}.diagnosis{margin-top:9px;border-left:3px solid #68e0b5;background:#101412;padding:9px;line-height:1.5}.danger{border-color:#ef7f7f}@media(max-width:680px){.top,.grid{display:grid;grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}}
</style>
</head>
<body>
<main class="shell">
<header class="top"><div><div class="id">AA-09 · DETERMINISTIC LAB</div><h1 id="title"></h1><p class="summary" id="summary"></p></div><button class="run" id="run">运行实验</button></header>
<section class="grid"><div class="panel"><div id="controls"></div><label class="toggle"><span>注入典型故障</span><input id="failure" type="checkbox"></label></div><div class="panel"><div class="metrics" id="metrics"></div><div class="stages" id="stages"></div><table><tbody id="rows"></tbody></table><div class="diagnosis" id="diagnosis"></div></div></section>
</main>
<script>
const scenario = { title: '模型路由、限流、重试与熔断', summary: '注入 429、超时或 5xx,观察退避、熔断、降级与幂等保护。', controls: [
{ key: 'fault', label: '上游故障', type: 'select', value: '429', options: [['none', '正常'], ['429', '429 限流'], ['timeout', '请求超时'], ['500', 'HTTP 5xx']] },
{ key: 'retries', label: '最大重试', type: 'range', min: 0, max: 6, value: 2, suffix: ' 次' },
{ key: 'breaker', label: '熔断阈值', type: 'range', min: 2, max: 10, value: 5, suffix: ' 次失败' }
] };
const controls = document.querySelector('#controls');
const failure = document.querySelector('#failure');
document.querySelector('#title').textContent = scenario.title;
document.querySelector('#summary').textContent = scenario.summary;
function renderControl(control) {
const label = document.createElement('label'); label.className = 'control';
const head = document.createElement('span'); head.className = 'head'; head.innerHTML = '<span>' + control.label + '</span><span class="value" data-value="' + control.key + '"></span>'; label.appendChild(head);
const input = document.createElement(control.type === 'select' ? 'select' : 'input'); input.dataset.key = control.key;
if (control.type === 'select') control.options.forEach(option => { const item = document.createElement('option'); item.value = option[0]; item.textContent = option[1]; item.selected = option[0] === control.value; input.appendChild(item); });
else { input.type = 'range'; input.min = control.min; input.max = control.max; input.step = control.step || 1; input.value = control.value; }
input.addEventListener('input', updateValues); label.appendChild(input); return label;
}
function updateValues() { scenario.controls.forEach(control => { const input = controls.querySelector('[data-key="' + control.key + '"]'); document.querySelector('[data-value="' + control.key + '"]').textContent = control.type === 'select' ? input.options[input.selectedIndex].text : input.value + (control.suffix || ''); }); }
function readValues() { const values = {}; scenario.controls.forEach(control => { const input = controls.querySelector('[data-key="' + control.key + '"]'); values[control.key] = control.type === 'range' ? Number(input.value) : input.value; }); values.failure = failure.checked; return values; }
function stage(name, state, detail) { return { name, state, detail }; }
const aiStage = stage;
function clamp(value, minimum, maximum) { return Math.min(maximum, Math.max(minimum, value)); }
function simulate(values) { const fail = values.failure;
/** 当前故障是否允许自动重试。 */
const retryable = ['429', 'timeout', '500'].includes(values.fault);
/** 带抖动指数退避的累计等待秒数。 */
const backoff = retryable ? Array.from({ length: values.retries }, function (_, index) { return Math.pow(2, index) * 0.5; }).reduce(function (sum, delay) { return sum + delay; }, 0) : 0;
/** 是否达到熔断阈值。 */
const opened = fail || values.retries >= values.breaker;
/** 最终是否切换到备用模型。 */
const fallback = values.fault !== 'none' && (opened || values.retries >= 2);
return { metrics: [[values.retries, '重试次数'], [backoff.toFixed(1) + 's', '累计退避'], [opened ? 'OPEN' : 'CLOSED', '熔断器'], [fallback ? 'BACKUP' : 'PRIMARY', '最终路由']], stages: [aiStage('限流', values.fault === '429' ? 'warn' : 'ok', values.fault), aiStage('幂等检查', fail ? 'fail' : 'ok', fail ? 'tool duplicated' : 'request-id'), aiStage('指数退避', retryable && values.retries ? 'ok' : 'warn', backoff.toFixed(1) + 's'), aiStage('熔断', opened ? 'warn' : 'ok', values.breaker), aiStage('降级模型', fallback ? 'ok' : 'warn', fallback ? 'backup' : 'unused')], rows: [['重试边界', values.retries > 3 ? '重试过多会放大拥塞和成本,应尽快降级' : '重试次数受控'], ['非幂等工具', fail ? '流式中途失败后重复执行扣款工具,幂等检查应阻断' : '工具调用携带稳定 idempotency key'], ['状态码策略', '429/5xx/超时可有限重试;参数错误和安全拒绝不应重试']], diagnosis: fail ? '检测到非幂等副作用风险,必须终止自动重试。' : fallback ? '主模型异常后按预算退避并切换备用模型。' : '主模型健康,熔断器保持关闭。', danger: fail };
}
function render() { const result = simulate(readValues()); document.querySelector('#metrics').innerHTML = result.metrics.map(item => '<div class="metric"><b>' + item[0] + '</b><span>' + item[1] + '</span></div>').join(''); document.querySelector('#stages').innerHTML = result.stages.map(item => '<div class="stage ' + item.state + '"><b>' + item.name + '</b><div>' + item.detail + '</div></div>').join(''); document.querySelector('#rows').innerHTML = result.rows.map(item => '<tr><td>' + item[0] + '</td><td>' + item[1] + '</td></tr>').join(''); const diagnosis = document.querySelector('#diagnosis'); diagnosis.textContent = result.diagnosis; diagnosis.className = 'diagnosis' + (result.danger ? ' danger' : ''); }
scenario.controls.forEach(control => controls.appendChild(renderControl(control))); updateValues(); document.querySelector('#run').addEventListener('click', render); render();
</script>
</body>
</html>