上一篇讲「怎么看见」,这篇讲「看见之后怎么办」。数据模型和工具选型见
docs/agent-observability.md。
系统慢了。第一反应是:换个更快的模型。
换完,P50 从 3.2s 降到 3.0s。花了钱,只快了 6%。
去看 span 时间轴才发现:
parse 0.1s
retrieve 1.6s ← 占了一半
generate 1.4s ← 换模型只优化了这里
其他 0.1s
因为瓶颈根本不在生成,换模型只是把 1.5s 变成了 1.3s。
这就是这篇要说的事:症状只有四个,但根因分布在四个层。改错层,努力全废。
| 症状 | 根因常在哪一层 | 典型改法 |
|---|---|---|
| 延迟高 | 架构层 | 并行、流式、模型分级 |
| 意图识别不准 | 决策层 | 分层判定、澄清、兜底 |
| 回复质量差 | 生成层 | 检索优化、代码校验、降级 |
| Token 烧得快 | 上下文层 | 裁剪、摘要、缓存 |

这四层不能互换。 用生成层的手段(改提示词)去解决架构层的问题(串行调用),怎么改都没用。
拿到观测数据后,很多人直接点开最慢的那一条 trace,然后基于这一条改架构。
这是第二个常见的浪费。 个例可能是异常值,不代表普遍情况。
正确的第一步是看两个数:
P50(中位数) 一半请求比它快
P95 5% 的请求比它慢
两者的组合指向完全不同的原因:
| 现象 | 说明 | 该往哪查 |
|---|---|---|
| P50 高,P95 也高 | 普遍慢 | 架构问题(串行、模型太大) |
| P50 正常,P95 很高 | 有长尾 | 超时重试、大输入、某些工具偶发慢 |
| P50 高,P95 正常 | 稳定地慢 | 每一步都慢一点,看 span 分布 |
「P50 正常但 P95 很高」是最容易误判的一种。 你会觉得「平时挺快的啊」,但 5% 的用户每次都在等十秒——而这类问题通常是重试策略或某个外部依赖的抖动,不是模型慢。
① 该并行的串行了(最常见)
// 三个独立检索,串行执行
const orders = await queryOrders(id) // 400ms
const policy = await queryPolicy(type) // 500ms
const faq = await queryFaq(question) // 300ms
// 总计 1200ms
// 并行:总计 = 最慢的那个
const [orders, policy, faq] = await Promise.all([...]) // 500ms
这一类最值钱:改三行代码,省 700ms,零成本。
② 没有流式输出
首字延迟和总耗时是两个完全不同的体验指标:
不开流式:等待 4.5s,然后一次性出现整段回答
开流式: 0.8s 出现第一个字,4.5s 时全部说完
总耗时一样,但用户感受差一个量级。 因为人判断「快不快」看的是有没有反应,不是什么时候说完。
排查方法:看 trace 里第一步 LLM 的 timeToFirstToken。如果这个数很小但总耗时很大,说明生成慢但首字不慢——这种问题开流式就能解决九成。
③ 简单任务用了大模型
意图识别 → 小模型足够(分类任务)
参数抽取 → 中等模型 + JSON mode
最终生成 → 大模型(这里才需要)
一个真实的配置错误:整条链路都用同一个大模型,包括「判断这句话属于哪个意图」这种三分类任务。这类任务小模型和大模型的准确率差不到 3%,但延迟差 5 倍。
④ 上下文太长
输入越长,首字延迟越高。而很多人会把所有检索结果一股脑塞进去:
topK=8,每片 800 字 → 6400 字 ≈ 4500 token
实际有用的可能只有 2 片
砍到 topK=3 + 阈值过滤,输入从 4500 token 降到 1600,首字延迟通常能降 30~40%。
⑤ 重试放大了延迟
第一次 超时 3s
第二次 超时 3s
第三次 成功 1s
总计 7s,而其中 6s 是重试
重试策略要看 P95,不要看平均值。 因为重试只影响长尾。
1. 并行化独立的调用 改几行,省几百 ms,零成本
2. 开流式 改配置,感知延迟降一个量级
3. 模型分级 简单任务换小模型,省一半
4. 砍上下文 阈值 + 裁剪,顺带省钱
5. 缓存 相同问题直接返回
6. 换更强的模型 ← 排在最后,因为最贵且常常没用
注意最后一行。 换模型是「万不得已」的手段,不是「第一反应」。
意图识别不准,第一反应是「分类模型不够好」。但实际项目里,七成的根因在意图体系设计,不在模型。
① 意图之间不互斥
❌ 退款 / 改签 / 查询订单 / 其他
「我要退掉这个订单」 → 退款 还是 查询订单?
「改签要加钱吗」 → 改签 还是 其他?
模型在两个意图之间摇摆,同一句话两次跑出两个结果——这时候你会误以为是模型不稳定,其实是定义有歧义。
判断方法: 拿 20 条真实问题,让两个人独立标注意图,如果一致性低于 90%,说明体系有问题。
② 规则和模型打架
规则命中就返回,看起来很省事。但规则是「字面匹配」,会误伤:
规则:「退」→ 退款意图
误伤:「退款政策是什么」→ 被判成退款(实际是咨询政策)
修法:规则只做「明显对」的判断。 拿不准的一律交给模型——规则误判比模型误判更难纠正,因为它不会给置信度。
③ 硬猜,没有澄清
置信度 0.4 也照样走某条分支,然后答非所问。
这是最容易被忽略的一条:与其猜错,不如问一句。
④ 没带上下文
同一句「能退吗」:
在订单详情页 → 退这一单
在客服首页 → 咨询退票政策
在帮助中心 → 想找退票入口
不带页面信息,这三句会分到同一个意图,然后走同一条错的路。
1. 先判「能不能处理」,再判「是什么」 ← 设计问题,零成本
2. 置信度分层:>0.8 走 / 0.5~0.8 澄清 / <0.5 转人工
3. 规则只做明显对的事,其余交模型
4. 把高频 badcase 固化成新规则
5. 接入页面/渠道等上下文
第 1 条是设计层面的,收益最大但最容易被跳过。
很多人只做「是哪个意图」,结果用户说「帮我写首诗」,分类器硬分到「其他」,然后走通用问答,答得又长又偏。
先判范围,是为了让「我帮不了你」成为一条正常路径,而不是异常。
拿一批标注数据,画两个数:
阈值调高 → 误判少,但澄清变多(用户觉得啰嗦)
阈值调低 → 澄清少,但答非所问变多
经验值:
> 0.8 直接走,不打扰用户
0.5 ~ 0.8 收敛到 2~3 个选项让用户点(不要开放式问)
< 0.5 不猜,转通用问答或人工
澄清要闭合,不要开放:
❌ 「请问您具体想咨询什么?」 → 用户再打一遍,还是分不出来
✅ 「您是想 ① 查订单 ② 申请退改 ③ 咨询政策?」
这两类问题的修法完全不同,混在一起会一直改错地方。
看 trace 里 retrieve 这一步的 output:
情况 A:retrieve 返回 0 条,或最高分 0.21 → 召回问题
情况 B:retrieve 返回 3 条且最高分 0.78 → 生成问题
A 和 B 的比例,决定了你该把时间花在哪。
根因和解法:
| 根因 | 表现 | 解法 |
|---|---|---|
| 切分破坏了语义 | 命中但 chunk 读起来不完整 | 按结构切,别硬切 |
| 向量模型不适合中文 | 同义改写召回差 | 换模型 / 加关键词一路 |
| 专有名词召不回 | SSE、E1024 这类查不到 |
加 BM25,用 RRF 融合 |
| topK 太小 | 答案在第 6 条 | 调大 k,但要配阈值 |
| 文档本身没有 | 怎么调都召回不到 | 补数据,别改代码 |
第三行是最常见的:纯向量检索对精确词天然弱。 我实测过,同一批问题里,关键词能精确命中而向量排不进前三的,占了两成。
根因和解法:
| 根因 | 表现 | 解法 |
|---|---|---|
| 资料里有,但被噪声带偏 | 答了不相关的内容 | 加阈值 + 重排 |
| 没有依据也在答 | 答得很顺但查不到出处 | 无召回就别调模型 |
| 引用编号错位 | 引用了不存在的 [5] |
代码剥 / 改,不靠提示词 |
| 结构缺失 | 少了某一段 | schema 约束 + 后处理补全 |
| 该拒答却硬答 | 问知识库外的问题也答 | 加阈值 + 拒答话术 |
这里有个必须强调的点:提示词只能降低概率,代码才能消除。
一个实测:系统提示词里明确写了「延伸说明段不要带引用编号」,4 道题各跑 3 次,12 次生成里有 9 次(75%)还是带了,其中 3 道题 3/3 稳定复现。
改成生成后用代码按段落剥掉,同一批从 75% 降到 0。
// 不靠提示词,靠代码
if (currentSection === 'extension') {
line = line.replace(/[ \t]?\[\d+\]/g, '')
}
所以「回复质量」这一层,一半的工作量在检索,另一半在后处理。真正属于「提示词工程」的,比想象中少。
Token 花得多,直觉是「模型太贵」。但先看 token 花在哪,往往有更便宜的解法。
把一次请求的 token 按来源拆开:
系统提示词 1800 token ← 32%
历史对话 2100 token ← 37%
检索结果 1500 token ← 27%
用户问题 50 token ← 1%
─────────────────────────────
合计 5450 token
这份分布会直接告诉你该动哪里。
很多人只盯着「换便宜的模型」,却没看过这 32% 是几句啰嗦的规则。
① 精简系统提示词
一条规则写三行和写一行,效果一样。删掉重复的、举例过多的、和业务无关的。
这一条零成本,且常常能砍掉 20~30%。
② 历史分层
最近 3 轮完整保留 ← 消解指代需要原文
更早的压成一段摘要 ← 只留结论
超过 N 轮直接丢 ← 极少需要
不要用「保留最近 10 轮」这种一刀切。 第 4 到第 10 轮里,大部分是寒暄和重复确认,压成两句话就够。
③ 检索结果按预算裁剪
const BUDGET = 2000 // token
let used = 0
const picked = []
for (const chunk of ranked) {
if (used + chunk.tokens > BUDGET) break
picked.push(chunk)
used += chunk.tokens
}
比固定 topK 好,因为不同 chunk 长度差很多。 固定 k=5 时,遇到 5 个长 chunk 就爆了。
④ 缓存
两层缓存:
答案缓存 相同问题 + 相同知识库版本 → 直接返回
Prompt 缓存 系统提示词固定 → 供应商侧缓存命中,成本大降
第二个容易被忽略: 大多数模型供应商对「相同的系统提示前缀」有缓存折扣。把固定的系统提示放在最前面、把变量放后面,命中率会高很多。
把上面四节压缩成一张操作表:
| 步骤 | 做什么 | 别做什么 |
|---|---|---|
| 1 | 看 P50 和 P95 的组合 | 别直接点开最慢的那一条 |
| 2 | 看 span 时间轴,找占大头的步骤 | 别猜 |
| 3 | 看那一步的 input 大小和模型 | 别急着换模型 |
| 4 | 按上面的表定位到「层」 | 别用生成层的手段修架构层 |
| 5 | 改一处,跑同一批用例对比 | 别一次改五处 |
第 5 条特别重要。 一次改五处,效果好了你不知道是哪一处起的作用,效果差了也不知道该回滚哪个。
改一处、测一次、才动下一处。 这听起来慢,但比「改一堆然后整体回滚」快得多。
① 延迟优化的第一手段不是换模型,是并行和流式。
前者省几百毫秒,后者把「感知延迟」降一个量级,两个都不花钱。
② 意图识别的问题,七成在意图体系设计,不在模型。
先做一次标注一致性检查:两个人独立标 20 条,一致性低于 90%,就别急着调模型。
③ 回复质量的一半工作量在检索和后处理,不在提示词。
提示词管不住采样——这是实测出来的,不是理论。
四个症状,四个层,一套顺序:
先看分布(P50 / P95)
→ 再定位层(架构 / 决策 / 生成 / 上下文)
→ 最后才改,且一次只改一处
观测的价值不是「发现问题」,是「定位到层」。 定位不到层,改动就是随机试错——而随机试错的成本,往往比问题本身更高。