给生产 Agent 做诊断,最容易被忽略的环节不是 Prompt,也不是模型,而是诊断开始之前的两个决定:哪些对话算数,哪些对话值得花一次 LLM 调用。前者决定统计口径的分母,后者决定成本的上限。这两件事没定义清楚,后面的一切指标——问题数、覆盖率、严重级别分布——都没有可信的解释。
本篇讲我们在数据层做的三件事:数据质检(定义”有效对话”)、Session 化(把散落的行还原成用户旅程)、风险筛选(从有效对话里挑出真正需要 LLM 看的候选)。核心思路可以压缩成一句话:
不是每条对话都值得花一次 LLM 调用,但每条对话都要被规则看一眼。
这套设计带来的直接效果是:LLM 诊断调用量显著下降,而关键的失败证据——用户差评的轮次、多轮对话的收尾轮、所有命中确定性风险信号的行——一条都没丢。

一、先看具体问题:直接全量诊断会发生什么
假设我们有一个电商客服 Agent,日志表里每天新增几万行对话记录,每个字段一行:用户问了什么、Agent 答了什么、耗时多少、用户是否点了差评。要做诊断,最朴素的做法是全量丢给 LLM:
for row in allRows:
await llmDiagnose(row)
这条路在真实数据上会撞到三类问题。
第一类是脏数据。 日志表里有重复上报的行、有 query 为空的行(用户只点了个按钮触发)、有 answer 截断的行。这些行进入 LLM 诊断,产出的不是诊断结论,而是噪声。更糟的是它们会污染分母——“共诊断 50000 条对话,发现问题 3000 条”,看起来问题率是 6%,实际上其中一部分”对话”根本不是对话。
第二类是成本。 全量诊断意味着绝大多数一次问答就结束、用户没有任何不满的”平淡对话”也各花一次 LLM 调用。这些调用绝大多数的产出是”无问题”。花钱买确定性,但买的是不需要确定的地方的确定性。
第三类是口径漂移。 一旦后来为了省成本改成抽样,报告里”问题数”的含义就变了:之前是全量里的问题数,现在是样本里的问题数。如果报告不写清楚,读报告的人(通常是 PM)会拿两期数字直接对比,得出完全错误的趋势判断。
这三个问题的共同根源是:诊断管道没有显式定义”人口”。所以我们的方案不是优化 LLM 调用本身,而是在 LLM 之前插一层确定性的数据层,把人口定义清楚。
二、数据质检:定义”有效对话”
数据层的第一步是质检。我们把输入数据建模成一张模板化的宽表,分必填列和可选增强列:
| 列分类 | 字段 | 说明 |
|---|---|---|
| 必填 | query | 用户本轮输入,非空才可能成为有效对话 |
| 必填 | answer | Agent 本轮回复,非空才可能成为有效对话 |
| 可选增强 | session_id | 会话标识,用于多轮还原;缺失时行自成单行会话 |
| 可选增强 | request_time | 请求时间,用于会话内排序 |
| 可选增强 | row_id | 行唯一 ID,时间相同时做稳定排序兜底 |
| 可选增强 | first_token_response_time | 首 token 耗时,规则诊断用 |
| 可选增强 | vote | 用户反馈(如点踩),规则诊断用 |
| 可选增强 | tool_calls | 本轮工具调用记录,作为诊断上下文 |
必填列的设计哲学是最小充分:只要 query 和 answer 都非空,这一轮交互在语义上就是完整的,LLM 就有东西可判断。其余列都是增强——缺失不阻断流程,只是让部分规则信号和上下文变弱。
质检做三件事:
interface QualityResult {
validRows: Row[];
duplicateCount: number;
emptyRequiredCount: number; // query or answer empty
}
function runQualityChecks(rows: Row[]): QualityResult {
const deduped = deduplicate(rows); // by content hash
const valid = deduped.filter(
r => isNonEmpty(r.query) && isNonEmpty(r.answer)
);
return {
validRows: valid,
duplicateCount: rows.length - deduped.length,
emptyRequiredCount: deduped.length - valid.length,
};
}
- 去重:重复上报在日志系统里几乎不可避免,同一行被消费两次就是重复计费一次。按内容哈希去重,而不是按时间戳——时间戳在重试场景下会变。
- 空 query 过滤:query 为空的行(纯按钮触发、心跳、系统事件)直接出局,它们不构成”对话”;answer 为空的行同样在此出局(截断、上报失败)。
- 有效对话判定:由此得到一个严格定义——
有效对话(valid instance):通过去重、且
query与answer均非空的行。
这个定义看起来平淡,但它是整个系统里最重要的一个口径。后面所有的 totalInstances、问题率、覆盖率的分母,都锚定在这里。定义一旦上线就不要轻易改——改了等于换掉了统计人口,历史数据立刻不可比。
三、Session 化:把行还原成旅程
质检解决”哪些行算数”,Session 化解决”这些行如何组织”。用户和一个客服 Agent 的真实交互是多轮的:“我的订单怎么还没到” → “订单号是 xxx” → “好的我等等” ——三行记录,一个旅程。诊断时如果把它们当成三条独立对话,会同时丢失两个信息:前文的语境(第三行的”好的”单独看毫无意义)和旅程的结局(问题到底解决没有)。
Session 化的规则刻意做得简单:
- 有非空
session_id的行,按session_id分组; - 没有
session_id的行,自成单行会话——不丢弃,不猜测归属; - 会话内按
request_time排序,时间相同的按row_id排序,保证顺序确定性。
排序兜底那个 row_id 值得多说一句:request_time 通常是毫秒甚至秒级精度,并发场景下同一会话的两轮可能拿到相同时间戳。没有 row_id 兜底时,排序结果取决于数据库返回顺序,同一份数据两次诊断可能产出不同的”最后一轮”,整个筛选结果就不可复现了。确定性管道里的每一步排序都要有全序的 tiebreaker。
至此,数据层的核心术语固定下来,后文全部沿用:
| 术语 | 英文 | 定义 |
|---|---|---|
| 有效对话 | valid instance | 通过质检的非重复行,query 与 answer 非空 |
| 会话 | session | 共享同一非空 session_id 的有序行集合;无 session_id 的行自成单行会话 |
| 候选对话 | candidate instance | 被选中送 LLM 诊断的有效行 |
| 已筛查对话 | screened instance | 经过确定性风险筛选逻辑处理的有效行(无论是否成为候选) |
注意 candidate 和 screened 的区分:所有有效行都被筛查过,但只有一部分成为候选。这个区分后面会直接体现在报告口径上。
四、确定性规则诊断:规则信号先于模型
在筛选候选之前,先跑一轮确定性的规则诊断——用代码给行打标签,不花一次 LLM 调用:
| 规则信号 | 判定方式 | 示例 |
|---|---|---|
| 首 token 超时 | first_token_response_time > threshold | 阈值可配置,如 5s |
| 用户差评 | vote 为点踩(downvote) | 显式负反馈 |
| 空洞回复 | answer 命中模板黑名单 | 如只回复”抱歉,请换个问法” |
| 工具失败 | tool_calls 中存在 error 状态 | 如物流查询接口超时 |
这里的设计原则是:能用确定性规则打出的标签,绝不交给 LLM。首 token 耗时是不是超阈值,比大小就够了,LLM 不会比大小更准,只会更贵、更不稳定。用户点没点差评,是一个布尔字段,LLM 的介入只会引入误判的概率。
规则标签有两重身份。第一重是诊断结论本身——“这轮首 token 耗时 8 秒”已经是可报告的问题证据,不需要 LLM 再确认。第二重更重要:它是下一节风险筛选的输入,命中规则标签的行会被强制选为 LLM 诊断候选。换句话说,规则负责”看得见”,模型负责”看得懂”,规则信号永远先于模型。
五、风险筛选:候选选择的四条规则(本篇核心)
现在到整条管道最关键的决策:哪些行值得花一次 LLM 调用?我们的候选选择算法是四条确定性规则,在数据质检和规则诊断之后执行:
function selectCandidates(validRows: Row[]): Set<RowId> {
const candidates = new Set<RowId>();
// Rule 1: any rule label forces candidacy
for (const row of validRows) {
if (row.ruleLabels.length > 0) candidates.add(row.id);
}
// Rule 2 & 3: session-level selection
for (const session of groupBySession(validRows)) {
if (session.rows.length >= 2) {
candidates.add(lastOrderedRow(session).id); // Rule 2
}
// Rule 3: one-row sessions contribute only via Rule 1
}
// Rule 4: multiple hits still yield one candidate (Set semantics)
return candidates;
}
逐条解释背后的取舍。
规则一:有规则标签的行必入选。 用户点了差评的轮次、首 token 超时的轮次,是已经暴露的风险证据。LLM 在这里的任务不是”判断有没有问题”,而是补全规则给不出的语义层:问题具体出在哪、严重程度如何、属于哪一类(知识缺失、工具误用还是意图理解偏差)。规则发现症状,模型诊断病因。
规则二:多行会话的最后一轮必入选,即使没有任何规则标签。 这是四条里最有设计感的一条。判断一个多轮用户旅程是否闭环——用户的问题到底解决了没有——最好的证据就是收尾轮。用户最后说的是”好的谢谢”还是”算了不用了”,这一个信号几乎概括了整个旅程的质量。而”旅程是否闭环”恰好是规则永远打不出来的标签:它是语义判断,只有 LLM 能做。所以我们把 LLM 的调用精准地花在这个判断价值最高的位置上。一个用户磨了七轮才得到答案,中间六轮可能都没有任何显式差评,但最后一轮的疲惫语气骗不了模型。
规则三:单行会话只看规则标签。 一问一答就结束的会话,没有”旅程闭环”可言,最后一轮就是唯一一轮。如果它干干净净——没有差评、没有超时、没有空洞回复——那么一次普通的问答顺利结束,LLM 再看一遍大概率产出”无问题”。这类对话通常是总量的大头,砍掉它们的 LLM 调用,是成本下降的主要来源。
规则四:多条件命中仍只算一个候选。 一个既被差评又是会话末轮的行,只进一次 LLM。用 Set 语义天然保证。这条不是优化,是正确性——否则诊断计数会膨胀,问题数会重复累计。
这套算法为什么能在大幅降本的同时不丢关键证据?因为它不是随机抽样,而是按证据密度定向筛选。三类高价值证据——显式负反馈(规则一)、性能异常(规则一)、旅程结局(规则二)——全部保留;被放弃的只有”单行、无任何风险信号”的对话,而这类对话恰恰是 LLM 最可能回答”无问题”的部分。放弃的是模型最没话可说的输入,留下的是模型最有话可说的输入。
还有两个容易被忽略的工程细节:
- 非候选行不落任何 LLM 状态。它们不会被持久化为”失败的诊断”或”待处理的诊断”。一旦给它们落了 pending 状态,重试逻辑和恢复逻辑就会试图”补齐”这些从未打算执行的调用,管道会被幽灵任务拖垮。
- 筛选必须可复现。同样的输入跑两次,候选集合必须相同。这就是前面 Session 排序要
row_id兜底的原因。
六、会话感知的执行分组:候选怎么送进 LLM
选完候选,下一个问题是执行编排。我们的原则:单次 LLM 请求绝不混入无关会话。
如果为了凑满批次把两个不相干会话的候选拼进同一次请求,模型会看到”用户 A 问物流、用户 B 问退货”的缝合上下文。轻则上下文互相稀释,重则模型把 A 会话的订单号”接”到 B 会话的追问里,产出跨会话的幻觉诊断。诊断管道里,上下文污染不是质量问题,是正确性问题。
所以执行单位是会话分组(session group):一个分组只包含来自同一会话的候选轮次。不同分组之间可以并发,分组内部保持会话顺序。每个候选在请求里仍是独立的诊断条目,携带三样东西:当前行、已有的规则标签、有界的前置历史上下文——即同一会话中排在它之前的若干轮。历史设上限是为了防止超长会话撑爆上下文窗口,也避免无关的远古轮次稀释模型注意力。
最后一个边界情况:单个会话的候选数超过请求批次上限怎么办?答案是按序切批——从同一会话里按顺序切出多个批次,而不是把溢出的候选拼到别的会话的批次里去。宁可多一次请求,不跨会话混装。伪代码示意:
function buildRequestGroups(candidates: Row[]): Row[][] {
const bySession = groupBy(candidates, r => r.sessionId);
const groups: Row[][] = [];
for (const [, rows] of bySession) {
const ordered = sortByRequestTimeThenRowId(rows);
for (const batch of chunk(ordered, MAX_BATCH_SIZE)) {
groups.push(batch); // batches from one session, in order
}
}
return groups;
}
与之配套的可靠性边界是:重试与 stale 任务恢复只作用于已持久化的候选诊断。任务中断后重启,恢复的对象是那些”已成为候选、已落库、未完成”的诊断,不会重新跑筛选、更不会给非候选行补诊断。候选集合在任务创建时冻结,之后的执行阶段只是一个有界的状态机。
七、覆盖率口径:两个分母不能混
筛选之后,报告遇到一个新问题:诊断覆盖了多少?这里必须严格区分两个分母:
diagnosed / candidates ✅ 正确的覆盖率口径
diagnosed / total ❌ 会误导的口径
diagnosed / total(有效对话里被 LLM 诊断过的比例)在筛选式管道里天然偏低,而且这个比例反映的是筛选率而非执行质量——筛选做得越好,这个数字反而越低。真正的执行质量指标是 diagnosed / candidates:候选里有多少被成功诊断了。这个数字接近 100% 才说明管道执行健康。
我们在任务和报告里显式区分这几个指标:
| 指标 | 含义 | 分母 |
|---|---|---|
totalInstances | 有效对话总数(筛查人口) | — |
candidateInstances | LLM 候选数(LLM 人口) | — |
diagnosedInstances | 已完成诊断的候选数 | candidateInstances |
failedInstances | 诊断失败的候选数 | candidateInstances |
coverageRate | 候选执行覆盖率 = diagnosed / candidates | candidateInstances |
报告正文中的问题计数,口径是”已完成候选中的问题数”,并在摘要里明示筛选范围——让读者不会误以为所有有效对话都被 LLM 逐条诊断过。这一行说明文字看起来不起眼,但它挡住了最常见的误读:拿筛选管道的问题数和全量管道的问题数直接对比。
错误处理也沿着这条口径走:LLM 调用失败只会降低候选覆盖率,不会把非候选行变成失败。判定任务进入终态的阈值,作用于候选覆盖率(存在候选时)。
八、边界情况:零候选与空报告
有一个边界情况值得单独说:零候选任务。如果一天的数据里,所有对话都是单行、干净、无规则标签——筛选结果为空——这个任务怎么收尾?
直觉的做法是标记”数据不足”,提示用户补充数据。但这是错的:数据明明是充足的,只是没有风险信号。一次全绿的体检应该产出一份”未发现风险信号”的空报告,而不是一个错误状态。用户看到空报告,得到的信息是”筛查人口 N 条,零候选,未触发任何风险规则”——这本身就是一个有价值的结论。把它标成”数据不足”,等于把”没病”和”没查”混为一谈,用户下次看到这个状态还是要来问一句。
所以规则是:零候选任务不发起任何 LLM 调用,正常完成,产出空诊断报告,附一条显式的筛选范围说明(selection-scope warning)。覆盖率此时不适用——diagnosed / candidates 的分母为零,报告里显式标注”零候选、不适用”,而不是显示一个误导性的 0%。降级、告警、重试这些为失败准备的动作,一个都不触发。
九、踩坑清单
最后是这条管道落地过程中踩过的坑,按出现频率排序:
- 排序不稳定导致筛选不可复现。会话内只按
request_time排序,同时间戳的行顺序漂移,“最后一轮”在两次运行中不是同一行,诊断结果前后矛盾。修复:任何排序都必须以row_id做最终 tiebreaker。 - 把无
session_id的行丢弃或强行归并。丢掉则总量口径缩小,归并则制造虚假旅程。正确做法是自成单行会话,让它们走”只看规则标签”的路径。 - 给非候选行落 pending 状态。看起来”方便以后补诊断”,实际上重试逻辑会持续扫描这些永远不会执行的记录,任务状态被卡在未完成。修复:非候选行不留任何 LLM 执行状态。
- 报告里只写一个覆盖率。筛选管道天然有两个分母,只写一个必然有人读错。修复:
totalInstances与candidateInstances并列展示,覆盖率显式绑定候选分母。 - 规则阈值硬编码。首 token 超时阈值 5s 还是 8s,不同业务场景差异很大。硬编码后每次调整都要发版。修复:阈值走任务配置。
- 去重按时间戳而非内容哈希。重试上报的行时间戳不同但内容相同,按时间戳去重漏掉重复,按内容哈希才能去干净。
十、局限与适用边界
这套设计不是万能的,有几个明确的边界:
- 它筛不出”安静的坏对话”。一段多轮对话,中间某轮 Agent 理解错了用户意图但用户没点差评、也没触发任何规则,且不是最后一轮——它会成为非候选。我们依赖的假设是:坏的旅程往往会在末轮或规则信号上留下痕迹。这个假设大多数时候成立,但不是永远成立。如果业务对”沉默的质量问题”敏感,需要定期跑小比例的随机对照诊断作为抽检。
- 规则信号的质量决定筛选的上限。垃圾进、垃圾出——如果
vote字段埋点有 bug、first_token_response_time采集缺失,规则一就名存实亡。数据层的健康需要独立监控。 - 筛选逻辑与业务形态耦合。“多轮会话看末轮”适配客服式交互;对于长程任务型 Agent(一次任务几十轮工具调用),末轮的信号价值下降,可能需要按里程碑轮次筛选。换业务形态时,四条规则要重新审视。
- 筛选管道引入了额外的确定性代码。它换来的是成本与可复现性,付出的是维护复杂度。如果对话量很小(比如每天几百条),全量诊断可能更简单,筛选层不值得。
十一、总结
回看这一整层设计,它其实反复在做同一件事:在模型之前,用应用代码把协议边界画清楚。什么是有效对话、什么算一个会话、哪个分母对应哪个指标、空候选产出什么状态——这些都是确定性判断,写代码比写 Prompt 可靠得多。而留给 LLM 的,是它真正擅长的那部分:理解一轮对话的语义、判断一个旅程是否闭环、给问题归类定级。
这也是整个系列的底层信条:模型负责语义判断,应用负责协议边界。本篇把人口和边界定义完了,下一篇进入模型一侧——如何让 LLM 把诊断结论以严格的结构化 JSON 稳定吐出来,而不是自由发挥一段谁也没法聚合分析的文本。