意图判定结果接进业务,顺序建议是:先把返回结构定死,再写解析与字段映射,最后定低置信度分流。业务侧只读固定字段,不去解析模型返回的自由文本。下面给的字段名是可替换的约定,模型实际输出的键名需要结合你当前部署的版本用一条真实响应确认一次,再落到代码里。
意图结果进业务,先定契约再调提示词。建议固定 intent、confidence、evidence、fallback 四个字段,并写清必填与可空;解析层必须把“解析失败”和“模型判成兜底类”分开记,前者改代码或改契约,后者补样例或补意图类目。低置信度阈值从验证集分布里取,不拍一个数就上线;阈值调整后用固定样例回放,确认已有分支没被打断。
先定返回结构:意图名、置信度、命中片段
下游系统只需要一张扁平表,字段别跟着模型的嵌套结构走。下面的结构可以在接入层组装出来,不要求模型原样返回;如果模型已经给了同名键,就直接透传,没给的由解析层补 null,不要用默认值假装有值。
| 字段 | 类型 | 必填 | 可空 | 用途 |
|---|---|---|---|---|
| intent | string | 是 | 否 | 规范化后的意图名,小写下划线,如 ticket_create |
| raw_intent | string | 是 | 是 | 模型原始返回的意图串,便于对账 |
| confidence | float | 是 | 是 | 0~1;模型未返回时置 null,不要填 1.0 |
| evidence | string | 是 | 是 | 命中片段原文,人工核对时看这一列 |
| fallback | bool | 是 | 否 | 是否判成兜底类/未识别 |
| parse_error | string | 是 | 是 | 解析阶段的问题码,正常时为 null |
| request_id | string | 是 | 是 | 回查模型调用日志用 |
与模型输出的对应关系写在接入层的注释里:raw_intent 经映射表得到 intent,映射不中就是 unmapped,不是猜一个最近的类目;confidence 缺失一律置 null 并标 needs_review,避免下游把它当成高置信度自动放过。
写一段解析与字段映射的代码骨架
解析函数放在接入层,业务代码只调它,异常在这一层收干净,不往上抛。下面这段是骨架,映射表按你自己的意图类目替换。
INTENT_MAP = {
"create_ticket": "ticket_create",
"查询工单": "ticket_query",
"refund": "order_refund",
"fallback": "unknown",
}
def parse_intent(resp):
out = {"intent": "unknown", "raw_intent": None, "confidence": None,
"evidence": None, "fallback": True, "parse_error": None,
"request_id": None, "needs_review": True}
try:
if not isinstance(resp, dict) or not resp:
out["parse_error"] = "empty_result"
return out
out["request_id"] = resp.get("request_id")
missing = [k for k in ("intent", "confidence", "evidence") if k not in resp]
if missing:
out["parse_error"] = "missing:" + ",".join(missing)
return out
raw = resp.get("intent")
out["raw_intent"] = raw
out["confidence"] = float(resp["confidence"]) if resp["confidence"] is not None else None
out["evidence"] = resp.get("evidence")
mapped = INTENT_MAP.get(raw)
if mapped is None:
out["parse_error"] = "unmapped_intent"
return out
out["intent"] = mapped
out["fallback"] = (mapped == "unknown")
out["needs_review"] = out["fallback"] or out["confidence"] is None
return out
except Exception as e:
out["parse_error"] = "exception:" + type(e).__name__
return out两种分支要分开:空结果(resp 为空、choices 缺失)说明调用或服务侧有问题,走重试或降级;缺字段说明模型输出结构变了,走告警给接入方,不要静默重试。两者 final 都落兜底队列,但日志问题码不同。
定低置信度的分流规则
阈值不要凭感觉。先用一批已标注的验证样本跑一遍,把 confidence 排序,按区间看每个切点会转多少条人工、会放过哪些错判样本;如果分数大量堆在中间一段,说明这个分数不宜直接当可信度用,阈值要往上取,多转人工。通常可以先在中间区间试一个档位,跑完回放和抽检再定,具体值需要结合你自己的分布确认。
def route(result, high=0.8, low=0.6):
if result["parse_error"] or result["fallback"]:
return "fallback_queue"
c = result["confidence"]
if c is None:
return "human_review"
if c >= high:
return "auto"
if c >= low:
return "ask_again" # 追问补槽位或让用户确认意图
return "human_review"中间档建议追问一次而不是直接转人工,成本低且能拿到更明确的文本;低档直接进人工或兜底队列,不要进自动流转。阈值改完要复验:重跑固定样例,再抽一段新样本对比转人工量和被拦住的错判,确认没有把原本正常的自动分支挤掉,再放开。
把解析失败和判成兜底类分开处理
这两件事经常被混成一个“识别失败”指标,混完就看不出到底该改谁。解析失败是接入层的问题,兜底类是模型或意图类目覆盖的问题,修复方向完全不同。
- 日志字段:解析失败记 event=intent_parse_error,带 parse_error 枚举(empty_result / missing:* / unmapped_intent / exception:*)、raw_payload 截断摘要、model_version;兜底类记 event=intent_fallback_hit,带 raw_intent、evidence、confidence、业务线。
- 告警:解析失败按问题码计数,短时间内同类问题码集中出现,说明模型输出结构变了或字段改名,直接告接入负责人;兜底类按业务线看趋势,属于覆盖问题,适合定期看而不是实时打扰。
- 返工动作:解析失败改解析代码或补字段兼容,必要时在映射表加别名;兜底类补标注样例、补意图类目、改提示词,或在模型前面加规则前置拦截明显可识别的请求。
用固定的十条样例回放整条链路
样例集写成一行一条,覆盖正常意图、边界表述、明显兜底、以及之前修过的 badcase。confidence 只比档位(high/mid/low),不比具体数值,否则模型一微调就全红,没人愿意跑。
{"case_id":"c01","input_text":"帮我提个工单,会议室网络断了","expect":{"intent":"ticket_create","route":"auto","bucket":"high"}}
{"case_id":"c02","input_text":"那个东西还没好","expect":{"intent":"unknown","route":"fallback_queue","bucket":"low"}}| case_id | 输入摘要 | 期望 intent | 期望 route | 期望档位 |
|---|---|---|---|---|
| c01 | 提工单,网络断了 | ticket_create | auto | high |
| c02 | 那个东西还没好 | unknown | fallback_queue | low |
| c03 | 我上个月的单子查一下 | ticket_query | auto | high |
运行方式:把样例和期望放在同一目录,先 dry-run 只打印解析结果和路由,不写库、不建单。
python replay.py `--cases` replay_cases.jsonl `--dry-run`通过标准:十条的 intent 与 route 全部一致,parse_error 全为 null;档位允许一档漂移但要逐条记录原因。只要出现解析失败或路由跳档,先回退这次改的解析代码或提示词,再单条定位,不要先改样例迁就结果。