用户只会说它胡说:AI 服务的一线报障怎么接
说明:本文讨论的是 AI 服务的一线报障受理流程,属于工程运维话题,不涉及具体模型版本与价格。AI 领域版本迭代极快,凡涉及版本号、价格、可用性,请以你阅读时的官方页面为准。文中代码为结构示意,请按自己的技术栈调整后再上生产。
一、"它胡说"这句话里没有信息
周一早上九点四十,支持群里进来一句:“你们这个 AI 胡说八道。”
后面通常没有别的了。你回一句"能具体说下吗",对方发来一张截图,截图里是一段看起来挺正常的回答。再问"你问的是什么",他说"就那个报销的事"。到这一步你花了二十分钟,手上仍然是零个可查询的条件。
这一章只说一件事:报障受理的第一步不是判断对错,是把一句自然语言变成能塞进检索框的条件。
用户给的,和他以为给了的
用户描述里通常只有三样东西:一个结论、一种情绪、一个可能并不存在的例子。
“胡说"在用户那里等价于"输出和我的预期不符”,而"预期"没法查。同一句"它乱答",可能对应他问的问题本身有歧义,可能对应材料里根本没有答案,也可能对应他换了入口、而那个入口的配置不一样。这几种在受理单上长得一样,在日志里一行都对不上。
情绪是有用的信号,但不是判据。"很生气"说明影响面,不说明该查哪张表。
例子最值钱,可用户给的往往是转述。他写"它说报销要找财务",原文可能是"报销申请需经财务部门复核后提交"。这两句丢进全文检索,命中率不是一个量级。所以要原文:我发了什么、它回了什么。
| 用户说的 | 工程师要的 | 差在哪 |
|---|---|---|
| “它胡说” | 一段原文问答对 | 结论对不上可检索的文本 |
| “就刚才” | 带时区的时间戳 | 相对时间无法对齐日志 |
| “你们那个网页” | entry 枚举值 | 口语名与字段名不同源 |
| “一直这样” | 重放三次的结果 | 印象替代不了记录 |
工程师要的是可查询条件
工程师能开工的前提,是手上有四个能直接用的东西:时间、会话、输入、入口。
- 时间落到分钟级,把窗口从"全部历史"缩到"那一次"
- 会话 ID 是唯一能把人带进日志的钩子,没有它只能靠时间加关键字碰运气
- 原始输入决定能不能重放,转述过的输入重放不出同一个问题
- 入口决定你看哪一套日志、哪一份配置
四样凑齐,第一动作可以是查;缺一样就退化成"你再试一次给我看看",响应时间从十分钟拉成一天。
缺口不该由用户来补
用户不知道你会记会话 ID,也不知道日志只保留一段时间。要求他提供不知道存在的东西,是把系统设计的债转嫁给用户,收回来的还是一堆没法用的描述。
正确的补法在三个地方:界面上放一个"复制本次会话"的按钮;报障入口自动带上时间、入口、请求 ID;表单只问系统拿不到的东西。都是一次性投入,做完每一单省十几分钟。
这一章可以带走的一句:用户没有义务提供工程信息,但系统有义务在他开口的时候顺手把信息收下来。
二、一线要收的最小信息包
"最小"这两个字有约束力:多一个字段,用户多一分放弃率;少一个字段,工程师多一次来回。下面六个是压不下去的下限。
六个字段,各自的作用
会话 ID 或任务 ID。它是索引键,不是给人看的。要求是能直接粘进检索框:长度对、字符集对、没被人为截断。
发生时间,带时区。精确到分钟就够,但必须能和你日志的时间轴对齐。跨时区团队最容易在这翻车:用户说"昨天下午三点",日志按 UTC 记,一减就是八小时。
原始输入。原文,不是摘要。概括会丢掉标点、空格、换行和附件名,而这些恰好影响上下文拼接。
期望与实际的差。写成两句话:“我以为它会做 X”“它给了 Y”。写成"答案不对"等于没写,因为"不对"没有方向。
是否可复现。分级的主要输入。三值就够:每次、偶尔、只出现过一次。“还没试"要单独留一项,不要塞进"只出现过一次”。
入口。网页、客户端、API、批处理。入口写错,后面所有查询都白做。
每个字段的合格线
字段定义里最容易漏的是"什么算填对"。只写"必填"没用,用户会填一个字形正确、内容错误的字符串,然后在受理单里躺三天。
| 字段 | 合格线(能直接用于查询) | 不合格的典型样子 |
|---|---|---|
| 会话 ID | 可直接粘贴检索、长度与字符集符合规则 | 只截了后六位、截图有反光 |
| 发生时间 | 带时区的 ISO 时间,误差 ±5 分钟 | “昨天下午”“刚刚”“上周” |
| 原始输入 | 用户实际发送的完整文本,可复制 | 一句概括、语音转文字后的残句 |
| 期望与实际的差 | 两句话,X 和 Y 都可判定 | “答得不对”“胡说八道” |
| 是否可复现 | 四值枚举之一,重放动作真实做过 | 空着、或者填"应该是每次" |
| 入口 | 枚举值,和日志里的 entry 字段同名 | “网页版”“就是你们那个” |
表单里的入口选项要和日志里的entry一一对应。写"网页版"、记web,中间就得有人翻译一次,翻译就会出错。
收不到的时候怎么兜
第一类,用户把会话删了。退一步收"输出里的一段原文",用那段原文做全文反查,通常能定位到唯一一条响应。要跟用户说明为什么需要,否则他会觉得你在刁难。
第二类,走的是 API,没有界面。这类用户一般能给请求 ID 或调用时间,以请求 ID 为主键,时间只作辅助。
第三类,不敢贴原文,里面可能有客户名、金额、证件号。要给可操作的替代:只贴结构、脱敏后贴、或者授权工程师按会话 ID 查看。授权要落在产品里,不能落在群聊里的"我同意"。
把六个字段固化成受理单:
⚠️ 代码待验证
ticket:id:t-20261024-0007entry:web# 与日志 entry 字段同名session_id:s_9f2c7a41# 用户可复制,直接进检索框happened_at:"2026-10-24T09:38:00+08:00"# 必须带时区raw_input:|帮我看下这张差旅报销单能不能过,附件是行程单和发票。expected:逐条列出不符合项actual_quote:|您的报销单符合规定,可以提交。reproducible:true# 四值:true / false / once / untestedself_check:replay:reproduced# reproduced / not_reproduced / changedother_entry:not_triedshrunk_input:reproduced每一行都要能翻译成一条查询条件;翻译不出来的字段,不该出现在表单里。
三、做成一句话模板:让用户填对
表单不是问卷,是检索条件的生成器
判断一个字段该不该留,只有一个标准:它会不会进入查询条件。不会的删掉。
“您的公司规模”"您是哪个部门"这类字段在工单系统里很常见,对统计有用,对受理没用。它让用户觉得在走流程,填写质量跟着下降,最后连该认真填的也敷衍了。
还有一条更硬的:系统能自己拿到的东西,一律不要问用户。浏览器 UA、入口、请求 ID、会话 ID、提交时间、用户标识,提交那一刻全都拿得到。把它们做成输入框,等于把自动化外包给最不可靠的一环。按这条砍一刀,十几项的表单通常剩三四个必填。
第一屏只放三个框
用户的耐心只够填三个框。第一屏放这三样:
- 你发了什么(多行文本,必填)
- 它回了什么(多行文本,必填,只要一两句原话)
- 什么时候(默认当前时间,可改,必填)
其余放进"补充信息"折叠区。代价是首轮信息不完整,收益是提交率:一个没提交的报障等于不存在。
字段定义写成配置,改起来不用发版:
⚠️ 代码待验证
{"form_id":"ai_incident_intake","version":3,"fields":[{"name":"raw_input","type":"textarea","required":true,"min_len":8,"label":"你发给它的内容","hint":"粘贴原文,不要概括"},{"name":"actual_quote","type":"textarea","required":true,"min_len":4,"label":"它回答的原话","hint":"一两句即可,不要截图"},{"name":"happened_at","type":"datetime","required":true,"auto_fill":"now"},{"name":"entry","type":"select","required":true,"auto_fill":"detect","options":["web","app","api","batch"]},{"name":"expected","type":"text","required":false,"max_len":120,"label":"你原本预期它做什么"},{"name":"reproducible","type":"radio","required":false,"options":["every_time","sometimes","once","untested"]}]}auto_fill的两个字段仍要显示,只是不让改:让用户看见系统已经知道他在哪个入口,比藏起来更让人放心。
校验:该拦的拦,该提示的提示
校验的尺度分三档,全都硬拦会把用户赶走。
| 情况 | 处置 | 理由 |
|---|---|---|
| 输入为空、时间在未来 | 硬拦,不许提交 | 提交了也查不了,是无效工单 |
| 输入少于 8 个字 | 软提示,可继续 | 有人真的只发了一个词 |
| 出现"胡说"“不对”“有问题” | 追加必填项:引用一句原话 | 这类词没有方向,必须补方向 |
| 时间早于日志保留期 | 硬拦,并说明保留天数 | 免得用户白填,也免得多一轮解释 |
| 会话 ID 格式不符 | 软提示,允许留空 | 有些用户确实拿不到 |
第三行是这套表单里最关键的一条:它是条件必填。平时"引用原话"是选填,用户一旦用了那些没信息量的词,它变成必填。用户不觉得被拦,只觉得系统在帮他把话说清楚。
实现上就是一个返回错误列表的函数,不要抛异常——错误要能一次全显示,而不是让用户改一条提交一次:
⚠️ 代码待验证
fromdatetimeimportdatetime,timedelta,timezone VAGUE_WORDS=("胡说","不对","有问题","乱答","答非所问")SUMMARY_HINTS=("它说","大概","类似","好像","意思是")defvalidate(payload:dict,log_retention_days:int=30)->list[str]:errors=[]raw=(payload.get("raw_input")or"").strip()ifnotraw:errors.append("请输入你发给它的内容")eliflen(raw)<8:errors.append("输入很短,确认一下是不是只发了一个词")elifsum(winrawforwinSUMMARY_HINTS)>=2:# 转述的典型特征:连着出现多个"它说/好像"这类词errors.append("这段看起来是转述,请粘贴你实际发送的原文")quote=(payload.get("actual_quote")or"").strip()ifany(winraw+quoteforwinVAGUE_WORDS)andnotquote:errors.append("请引用一句它的原话,这样我们才能定位到具体回答")ts=parse_ts(payload.get("happened_at"))iftsisNone:errors.append("时间格式不对,需要带时区")elifts>datetime.now(timezone.utc)+timedelta(minutes=5):errors.append("时间在未来,检查一下时区")elifts<datetime.now(timezone.utc)-timedelta(days=log_retention_days):errors.append(f"这个时间已经超出日志保留期({log_retention_days}天)")returnerrors最后一个分支不是拦住用户,是提前告诉他这条查不到了——否则他会提交,然后等一个"查不到"的回复。
四、自助诊断:三个动作,让用户先排除一半
三个动作,按成本排序
自助诊断不是让用户替工程师干活,是让他在两分钟内把三种最可能的分岔走掉。
| 动作 | 用户要做什么 | 结果怎么记 | 能分掉哪一类 |
|---|---|---|---|
| 原样重放 | 点一下"再问一次" | 稳定复现 / 偶发 / 换了个错法 | 稳定性与内容问题 |
| 换一个入口 | 同一句在另一个入口再问 | 复现 / 不复现 / 没权限 | 入口之间的配置差异 |
| 缩小输入 | 去掉附件、去掉前半段、缩短指令 | 仍出错 / 变正常 | 与输入长度或形态相关 |
顺序不能颠倒:重放最便宜,一秒做完;缩小输入最贵,用户得重新组织问题。让用户先做贵的,他多半直接放弃。
三个动作各自的读法
原样重放。关键是"原样":同一段输入、同一个入口、不改一个字。所以产品上要提供按钮,不要让用户手工复制粘贴——手工重放十有八九会顺手改一两个字。结果只有三种:每次都错,和输入内容强相关;偶尔错,和采样或并发有关;换了个错法,说明这段输入踩在边界上。
换入口。同一份能力在不同入口上往往差着系统提示、超时、附件解析开关、检索是否开启。换入口能复现,说明现象不是某个入口独有;换入口不复现,说明差异在入口配置里。这一条能挡掉很多"你们模型不行"的误报。
缩小输入。三种砍法:去掉附件、去掉前半段对话、把长文档换成一段摘要。三种结果:短输入正常,和长度或截断有关;短输入也错,和内容有关;怎么砍都正常,和当时的并发或时间有关。
这里有个容易忽略的点:"缩小后正常"不等于问题消失了。它只说明触发条件包含长度。受理单上要如实记这个结果,而不是记成"用户误报"。
结果要能回到受理单
自助做完了,结果必须变成结构化字段回传,否则自助只是把问题又推回给用户,还多花了他两分钟。
三个字段就够,每个都是三值枚举:
replay:reproduced / not_reproduced / changedother_entry:reproduced / not_reproduced / not_triedshrunk_input:reproduced / normal / not_tried
这三个字段直接决定分级。replay=reproduced且shrunk_input=normal的单子,比一个什么都没试过的值钱得多,接手的人第一眼就知道往哪查。
自助也有边界。涉及计费、权限、数据删除的现象,不要让用户自己试——他每试一次都可能产生真实副作用。这类直接进人工,别省那两分钟。
五、分级与升级路径
三级,判据写死
分级的难点不在分级本身,在"什么算严重"这句话没有共识。所以判据必须是可判定的条件,不能是形容词。
| 级别 | 判据(满足任一) | 响应时限 | 谁接 |
|---|---|---|---|
| 一级 | 核心入口有请求但无任何输出;或 30 分钟内 3 个以上不同用户报同一现象 | 15 分钟内有人回应 | 当班工程师 + 负责人 |
| 二级 | 单用户、可复现、有替代路径可用 | 当日工作时间内 | 当班工程师 |
| 三级 | 单次、不可复现、用户已自行绕过 | 每周归并时统一看 | 受理人登记即可 |
"核心入口有请求但无任何输出"和"答得不对"是两件事。前者是服务没回,后者是回了但内容不合预期。放进同一个级别,真正该立刻处理的那类会被淹没。
"30 分钟内 3 个以上不同用户"有两个限定词:不同用户排除同一个人反复提交,30 分钟内排除跨越一周的偶发。少任何一个,这条判据都会误报。
判据写成代码比写成文档可靠,因为代码不会在不同人的嘴里变成不同意思:
⚠️ 代码待验证
fromdatetimeimportdatetime,timedelta REQUIRED=("entry","happened_at","reproducible","has_output")LEVEL_1,LEVEL_2,LEVEL_3="L1","L2","L3"defgrade(ticket:dict,same_phenomenon_users:int,now:datetime|None=None)->tuple[str,str]:missing=[kforkinREQUIREDifticket.get(k)isNone]ifmissing:# 缺字段不猜级别,直接打回补信息raiseValueError(f"受理单缺字段:{missing}")now=nowordatetime.now()ifticket["entry"]=="core"andticket["has_output"]isFalse:returnLEVEL_1,"核心入口有请求无输出"ifsame_phenomenon_users>=3andticket["happened_at"]>=now-timedelta(minutes=30):returnLEVEL_1,"30 分钟内多用户同一现象"ifticket["reproducible"]isTrue:returnLEVEL_2,"单用户可复现"returnLEVEL_3,"单次不可复现"那个raise是有意为之。缺字段时按默认值猜一个级别,等于把一个信息不完整的单子伪装成完整的,后面所有人都被这个假级别骗。
允许跳级,不允许随手降级
往上跳要容易:任何人都能标一级,代价是必须填一段理由。这条不是防滥用,是防沉默——宁可多几个误报,也不要一个被压在三级里的真故障。
往下调要难:只能由接手人看过之后调,并写清原因。最常见的坏习惯是"先标三级,等有空再看",这一压往往就是两天。
还有一条:时限从提交时刻算,不从接单时刻算。从接单算的话,一直没人接的单子永远不会超时,时限形同虚设。
超时之后自动往上走
一级 15 分钟没人回应,自动通知上一层;二级到当日结束还没人接,次日自动升为一级并附上"已积压一天"。这套动作是自动的,不依赖任何人记得。
只有"超时未回应"才升,"回应了但没解决"不升:前者是流程问题,后者是技术问题,处理的人手不一样。
完整版资料清单:本文用到的受理单模板、分级判据表和术语对照都整理在里面了,扫码即可获取:
六、工程师接手后的第一条动作
把"胡说"翻译成三个查询条件
接手后的第一件事不是看模型答得对不对,是确认用户说的那一次到底是哪一次。
拿三个条件去定位:时间窗(用户报的时间前后各 15 分钟)、会话 ID(精确匹配)、入口(枚举值)。三个里至少两个要落在索引上,否则查询会退化成全表扫描。
这一步必须放在最前面,原因是受理单上的时间是"用户记得的时间",不是"请求发生的时间"。人会记错半小时,会把"昨天晚上"说成"昨天下午",会把自己在另一个入口的操作记成这个入口。先把那一次请求找出来,后面的讨论才有共同对象。
这一步还常带来一个意外发现:用户说的那次,根本不在他填的入口里。到这儿问题就从"内容不对"变成了"受理单填错入口",处置动作完全不同。
一条能跑的查询
查询本身没技巧,技巧在条件上。下面这条带上租户条件,是因为多租户下session_id有可能重复——两个租户各自生成了一样的短 ID,不带租户就会拉出别人的会话,既查错也越权。
⚠️ 代码待验证
-- 按时间窗拉一条会话的全部请求-- tenant 条件不能省:session_id 在多租户下可能重复SELECTr.request_id,r.turn_index,r.entry,r.created_at,r.status,r.finish_reason,r.input_chars,r.output_charsFROMrequest_log rWHEREr.tenant_id=:tenant_idANDr.session_id=:session_idANDr.created_at>=:from_tsANDr.created_at<:to_tsORDERBYr.created_atLIMIT200;ORDER BY created_at是为了还原对话顺序:少了顺序,你会看到一堆并列的请求,判断不出上下文是怎么被拼起来的。LIMIT 200是保险——长时间挂着的会话里上千轮请求并不罕见,不设上限会把结果集撑爆。
受理单上由接手人填的四个字段
受理单分两半:用户填的那半,和接手人填的那半。后一半只有四个字段,但每个都能被追问。
| 字段 | 填什么 | 为什么必须有 |
|---|---|---|
| 首查条件 | 实际用了哪几个条件、命中几条 | 让别人能原样复现你的检索 |
| 定位到的请求 | request_id 与 turn_index | 把讨论对象钉死在一个请求上 |
| 信息完整度 | 六个字段里缺哪几个 | 缺字段的单子结论要打折看 |
| 下一步归属 | 转给谁、还是退回补信息 | 只写归属,不写结论 |
第四个字段要克制。受理人写"应该是模型幻觉"“估计是检索没召回”,看起来有帮助,实际是在没有证据时给接手人预设方向。
“查到 0 条"也是有效结果。它说明三件事之一:入口填错、时间窗错、或者这次请求根本没落日志。三种的下一步完全不同,所以要当成正常分支记下来,而不是当成"没查到,再问问”。
七、把重复报障变成规则
一周一次归并,按现象不按人
单看一条报障,什么规律都看不出来。攒一周再看,重复的部分会自己浮出来。
归并的三个维度是:现象、入口、输入形态。不按用户归并,因为同一个用户可能报三件不同的事,三个用户也可能报同一件事。
“现象"要归一化。用户写"胡说”“答非所问”“牛头不对马嘴”,归并时要落到同一个标签上。这一步靠人做,不要指望关键词自动聚类——中文里同一个意思的说法太多,自动聚出来的组通常没法看。
阈值建议是 5:同一类一周内到 5 条才升级成"待处理类",不到的留在池子里,下周继续累计。低于阈值的强行归类,会得到一堆只出现过一两次的类,改完表单也没人用。
归并之后要改三样东西
归并结果如果只写进周报,等于没做。它必须回写到三个地方,而且最好是同一个人改:
- 表单选项:把高频现象做成下拉项。用户点一下,分类就带上了,比让他用自然语言描述准确得多。
- 自助话术:这一类如果有稳定的自助解法,写进自助诊断的提示语。比如"这类问题多数和附件解析有关,可以先去掉附件试一次"。
- 知识库条目:一条现象、一条判据、一条处置动作,三行写完。不要写长文,长文没人看,也没法在受理时快速对照。
三样必须同步。最常见的破败样子是:表单里多了一个选项,知识库里没有对应条目,接手人看到这个选项也不知道该干什么,最后还是回去问用户。
什么不该归并
第一类是时间上高度集中的。一小时内进来的 20 条,很可能是同一次故障的 20 个用户。归并成 1 条,等于把影响面从 20 改成 1,级别也跟着从一级掉到三级。这类要合的是"处置动作",不是"计数"——20 条一起处理,但 20 这个数字必须留着。
第二类是处置动作不同的。两条描述几乎一样,但一条要改配置、一条要补数据,它们就不是同一类。归并的粒度是"处置动作相同",不是"关键词相同"。
统计脚本可以自动做完候选分组,剩下的判断留给人:
⚠️ 代码待验证
fromcollectionsimportdefaultdictdefcluster(tickets:list[dict],min_count:int=5)->tuple[list[dict],list[dict]]:buckets=defaultdict(list)fortintickets:key=(t["phenomenon"],t["entry"],t["input_shape"])buckets[key].append(t)ready,pending=[],[]forkey,itemsinbuckets.items():row={"phenomenon":key[0],"entry":key[1],"input_shape":key[2],"tickets":len(items),"users":len({i["user_id"]foriinitems}),"ticket_ids":[i["id"]foriinitems],}(readyiflen(items)>=min_countelsepending).append(row)ready.sort(key=lambdar:r["tickets"],reverse=True)returnready,pending输出里特意带了users:条数多但用户数只有 1,说明是同一个人在反复提交,不该进"待处理类",该去看他为什么反复提交。
完整版资料清单:本文用到的受理单模板、分级判据表和术语对照都整理在里面了,扫码即可获取:
附表 A:关键取舍一览
| 取舍 | 本文结论 | 判断依据 | 位置 |
|---|---|---|---|
| 受理第一步做什么 | 先转成查询条件,不判断对错 | 没有条件就没有可查对象 | 第一章 |
| 信息缺口谁来补 | 系统补,不追问用户 | 用户不知道哪些东西存在 | 第一章 |
| 表单首屏 | 只放三个必填框 | 提交率优先于首轮完整度 | 第三章 |
| 模糊描述怎么处理 | 条件必填"引用原话" | 硬拦会赶走用户,放任则无法定位 | 第三章 |
| 自助动作顺序 | 重放 → 换入口 → 缩小输入 | 按用户操作成本排序 | 第四章 |
| 分级判据写法 | 可判定条件,不写形容词 | "严重"没有共识,无法执行 | 第五章 |
| 降级权限 | 只有接手人能降,且写原因 | 防止"先压三级再说" | 第五章 |
| 时限从哪算 | 从提交时刻算 | 从接单算则无人接单永不超时 | 第五章 |
| 接手第一条动作 | 先确认是哪一次请求 | 用户记的时间不等于请求时间 | 第六章 |
| 受理人写不写结论 | 只写归属,不写结论 | 无证据的预设会带偏后续 | 第六章 |
| 归并按什么聚类 | 现象 + 入口 + 输入形态 | 按用户聚类会把不同问题混在一起 | 第七章 |
| 密集报障怎么处理 | 合处置不合计数 | 合计数会把影响面算小 | 第七章 |
附表 B:术语速查表
| 术语 | 含义 |
|---|---|
| 最小信息包 | 让工程师能开始检索的最少字段集合,本文定为六个 |
| 会话 ID | 一次连续对话的标识,是定位日志的主键 |
| 入口 | 用户实际使用的接入方式,如网页、客户端、API、批处理 |
| 可查询条件 | 能直接落进检索或数据库条件的值,如时间窗、精确 ID |
| 原样重放 | 同一输入、同一入口、不改一字地再执行一次 |
| 分级判据 | 用来判定报障级别的可判定条件,不含形容词 |
| 超时升级 | 超过响应时限仍无人回应时,自动提升级别并通知上一层 |
| 现象归一化 | 把不同说法归到同一个现象标签,通常由人完成 |
写在最后:这篇用到的资料
写这篇文章时,我把几个模型的官方文档、参数表和实测记录都对了一遍,顺手整理成几份配套的东西:
- 大模型学习路线图:从 LLM 基础到 Agent 开发,各阶段该学什么、用什么资料
- 大模型全套教程:按主题分好的视频与文档清单
- 大模型实战好书:24 本,附每本适合的阶段
资料是我自己整理的,放在下面这个码上,扫码即可获取:
添加时备注「大模型」,优先通过。
拿到之后建议先看学习路线图那一份,先定位自己在哪个阶段,再决定学什么,比一上来就啃框架效率高得多。