1. 项目概述:为什么“填槽+自定义类型+checkpoint”是语音助手落地的临门一脚
LangGraph 做语音助手,很多人卡在第九步——能听、能说、能调用工具,但一到真实业务场景就露馅:用户说“我要订一杯热美式,不加糖,送到3号楼前台,下午三点”,系统要么漏掉“不加糖”,要么把“3号楼”当成订单号,要么下午三点直接变成“15:00:00”塞进数据库报错。这不是模型不行,是槽位工程没做透。标题里这三件事——“用 LLM 填充订单槽位”、“支持自定义类型”、“引入 checkpoint”,表面看是 LangGraph 的几个 API 调用,实则对应语音助手从 Demo 走向生产环境的三个生死关卡:语义理解的鲁棒性、业务数据的强约束性、多轮对话的状态韧性。
我带过两个团队落地过类似项目:一个面向高校食堂的订餐语音助手,另一个是某制造企业内部的设备报修语音入口。前者失败在槽位识别飘忽——用户说“少放辣椒”,系统有时填进“口味偏好”,有时填进“备注”,有时直接丢弃;后者崩溃在第三轮对话——用户刚说完“设备编号是A7X-2024”,系统正要问“故障现象?”,电话突然中断,再拨进来时一切重来,用户暴躁挂机。后来我们把整个槽位填充逻辑重构成 LangGraph 的子图,强制所有槽值必须经过类型校验器(比如“设备编号”必须匹配正则^A\d{2}X-\d{4}$),并在每轮意图确认后写入 checkpoint,上线后用户平均对话轮次从 6.8 轮降到 3.2 轮,中止率下降 73%。所以这不是炫技,是解决真实痛点的最小可行方案。如果你正在用 LangGraph 搭建需要收订单、填表单、走流程的语音交互系统,这篇就是你跳过试错期的直通电梯——不讲原理推导,只说怎么让 LLM 老老实实按你的业务规则填空,怎么让自定义类型真正拦住脏数据,以及 checkpoint 怎么存、存哪里、断线后怎么精准续上。
2. 核心设计思路拆解:为什么必须把“填槽”做成可验证的子图,而不是扔给 LLM 自由发挥
2.1 槽位填充不能交给 LLM “自由发挥”的三大硬伤
很多初学者会这么写:
# ❌ 危险写法:让 LLM 一次性输出 JSON prompt = """你是一个订单助手,请从以下对话中提取字段: - 饮品类型(必填,选项:美式/拿铁/摩卡/抹茶) - 温度(必填,选项:热/冰/常温) - 糖度(选填,选项:无糖/半糖/全糖/另说明) - 送达地点(必填,格式:X号楼Y层Z区域) - 送达时间(必填,转为24小时制HH:MM) 对话记录: {messages} 请严格按JSON格式输出,不要任何解释。"""这种写法在测试集上准确率可能高达 92%,但上线后立刻崩盘。原因有三:
LLM 的“幻觉补偿”机制:当用户说“我要一杯咖啡”,没提温度,默认填“热”看似合理,但某天用户说“夏天了,来杯冰的”,模型却因训练数据中“咖啡=热”占比高,仍填“热”。这不是错误,是统计偏差——而订单系统里,“温度”是必填项,缺值即失败。
结构化输出的脆弱性:LLM 输出 JSON 时,哪怕多一个逗号、少一个引号、字段名拼错(如
"sugar_level"写成"suger_level"),下游解析直接抛异常。我们曾在线上日志里抓到 17 种不同格式的 JSON 变体,最离谱的是模型返回了带中文引号的“temperature”:“热”。无法嵌入业务强校验:饮品类型必须是枚举值,但 LLM 可能输出“美式咖啡”(超长)、“Americano”(英文)、“美式(热)”(带括号)。这些在数据库字段
VARCHAR(20)上会截断或报错,而 LLM 本身根本不知道什么叫字符长度限制。
提示:真正的生产级填槽,不是“让 LLM 猜答案”,而是“让 LLM 在你画的格子里填数字”。格子就是 schema,填数字就是 type validation + normalization。
2.2 LangGraph 的子图设计:把填槽拆成“感知-校验-归一”三步流水线
我们最终采用的架构是将fill_slots封装为独立节点,并在其内部实现三级流水线:
| 步骤 | 功能 | 关键技术点 | 为什么必须这一步 |
|---|---|---|---|
| Step 1:LLM 感知(Perceive) | 接收原始对话,用 few-shot prompt 引导 LLM 输出带置信度的候选槽值,格式为[{"slot": "temperature", "value": "热", "confidence": 0.92}] | 使用response_format={"type": "json_object"}强制 JSON;在 prompt 中明确要求 confidence 字段;设置 temperature=0 抑制随机性 | 避免自由发挥,获得可量化的可靠性评估,为后续人工兜底留接口 |
| Step 2:类型校验(Validate) | 对每个候选值调用对应 slot 的 validator 函数,返回(is_valid, normalized_value, error_msg) | 为每个 slot 定义 validator 类(如TemperatureValidator继承BaseSlotValidator);validator 内部做枚举匹配、正则校验、日期解析等 | 把业务规则代码化,LLM 不负责对错,只负责提供线索;校验失败时可触发澄清话术 |
| Step 3:归一存储(Normalize & Store) | 合并多轮结果,处理冲突(如用户先说“热美式”,后说“换成冰的”),写入 state 并触发 checkpoint | 使用state.update()而非覆盖;对时间类槽位统一转为datetime.time对象;对地址类槽位调用地理编码 API 标准化 | 确保 state 中的值是下游服务可直接消费的格式,避免各模块重复解析 |
这个设计把 LLM 降级为“高精度传感器”,把规则引擎变成“质量检验员”,把状态管理变成“中央档案室”。LangGraph 的优势在于,你可以把这三步封装成一个黑盒节点,对外只暴露input: messages, output: filled_slots,内部迭代优化不影响上游节点。
2.3 为什么 checkpoint 必须与槽位状态强绑定,而不是简单存整个 state?
新手常犯的错误是:checkpointer = SqliteSaver.from_conn_string(":memory:"),然后app = workflow.compile(checkpointer=checkpointer)。看起来很美,但实际运行会发现两个致命问题:
- Checkpoint 数据膨胀:LangGraph 的 state 是嵌套字典,包含 messages、tools_called、intermediate_results 等大量临时数据。一次对话存 5MB,1000 个并发就是 5GB,SQLite 直接卡死。
- 恢复时逻辑错乱:用户中断后重启,系统从 checkpoint 加载 state,但此时
messages里还存着上一轮的“请问您要什么温度?”,而新对话已开始,bot 再次发问,造成重复提问。
我们的解法是:只 checkpoint 业务关键状态(business-critical state)。具体操作:
在
fill_slots节点执行完毕后,显式提取需持久化的字段:# ✅ 精确提取 critical_state = { "order_id": state.get("order_id", generate_order_id()), "slots_filled": {k: v for k, v in state["slots"].items() if v is not None}, "last_confirmed_slot": state.get("last_confirmed_slot"), "step": "awaiting_confirmation" # 当前业务阶段 }将其存入独立的业务数据库(如 PostgreSQL 的
voice_orders表),而非 LangGraph 的通用 checkpointer:CREATE TABLE voice_orders ( session_id TEXT PRIMARY KEY, order_id TEXT NOT NULL, slots_filled JSONB NOT NULL, step VARCHAR(32) NOT NULL, updated_at TIMESTAMPTZ DEFAULT NOW() );中断恢复时,不调用
app.invoke(..., config={"configurable": {"thread_id": ...}}),而是:- 先查
voice_orders表获取session_id对应的step和slots_filled - 构造最小化初始 state:
{"messages": [SystemMessage(...)], "slots": slots_filled, "step": step} - 调用
app.invoke(initial_state, config={"configurable": {"thread_id": session_id}})
- 先查
这样,checkpoint 体积从 MB 级降到 KB 级,恢复逻辑完全可控,且与业务系统无缝集成——订单状态变更可同步触发短信通知、库存预占等下游动作。
3. 核心细节与实操要点:自定义类型如何真正“拦住”脏数据,而不是走个过场
3.1 自定义类型不是写个 Pydantic Model 就完事——它必须能“主动拦截”和“智能修复”
很多教程教这么写:
from pydantic import BaseModel, Field class OrderSlots(BaseModel): beverage: str = Field(description="饮品类型,必须是枚举值") temperature: str = Field(description="温度,必须是枚举值") # ... 其他字段然后传给 LLM 的response_format。这只能保证输出是 JSON,但拦不住"beverage": "美式咖啡"这种超长字符串。真正的自定义类型要能:
- 拦截:当值不满足业务规则时,拒绝接受并给出明确错误;
- 修复:对模糊输入(如“美式”、“美式咖啡”、“americano”)自动映射到标准值
"美式"; - 引导:在拦截时生成自然语言提示,告诉用户该怎么说。
我们为每个槽位开发了SmartValidator类,以BeverageValidator为例:
class BeverageValidator(BaseSlotValidator): # 标准值集合(业务唯一真理源) STANDARD_VALUES = {"美式", "拿铁", "摩卡", "抹茶"} # 模糊映射表(运营可配置) FUZZY_MAP = { "美式咖啡": "美式", "americao": "美式", "latte": "拿铁", "mocha": "摩卡", "matcha": "抹茶", "绿茶拿铁": "抹茶" } def validate(self, raw_value: str) -> ValidationResult: # Step 1: 精确匹配 if raw_value in self.STANDARD_VALUES: return ValidationResult(is_valid=True, normalized_value=raw_value) # Step 2: 模糊匹配 if raw_value.lower() in self.FUZZY_MAP: norm = self.FUZZY_MAP[raw_value.lower()] return ValidationResult( is_valid=True, normalized_value=norm, warning=f"已将'{raw_value}'自动修正为'{norm}'" ) # Step 3: 拦截 + 引导 suggestions = list(self.STANDARD_VALUES)[:3] # 取前3个建议 return ValidationResult( is_valid=False, error_msg=f"饮品类型 '{raw_value}' 不在可选范围内。请选择:{', '.join(suggestions)}", suggestion_options=suggestions )这个 validator 被注入到fill_slots节点中,在 Step 2 校验阶段调用。当 LLM 输出"beverage": "美式咖啡"时,它不会报错,而是静默修正为"美式"并记录 warning;当输出"beverage": "珍珠奶茶"时,则返回is_valid=False,触发 bot 回复:“抱歉,我们目前提供美式、拿铁、摩卡、抹茶四种饮品,您想选哪一种?”
注意:
FUZZY_MAP表我们放在 Redis 里,运营后台可随时增删。上线后,客服反馈“用户常把‘摩卡’说成‘墨咖’”,我们 2 分钟就加了"moka": "摩卡",无需发版。
3.2 时间槽位的魔鬼细节:如何把“下午三点”、“15点整”、“3pm”全转成datetime.time(15, 0)
时间是最容易翻车的槽位。用户输入千奇百怪,而数据库要求精确到分钟。我们不用dateutil.parser(太重,且对口语支持差),而是手写轻量解析器:
import re from datetime import time class TimeValidator(BaseSlotValidator): # 匹配“下午三点”、“三点”、“15点”、“3pm” PATTERNS = [ (r'([下上]午)?(\d{1,2})[点时]', self._parse_chinese_time), # 下午三点 → 15:00 (r'(\d{1,2})[::](\d{1,2})', self._parse_hhmm), # 15:00 → 15:00 (r'(\d{1,2})[点时]整', self._parse_chinese_hour), # 三点整 → 15:00 (r'(\d{1,2})\s*(am|pm)', self._parse_12h), # 3pm → 15:00 ] def _parse_chinese_time(self, match) -> Optional[time]: period, hour_str = match.groups() try: hour = int(hour_str) if period == "下午" and hour < 12: hour += 12 elif period == "上午" and hour == 12: hour = 0 elif period is None and hour == 12: # 默认上午12点是中午,下午12点是午夜?按业务定 pass return time(hour % 24, 0) except: return None def _parse_hhmm(self, match) -> Optional[time]: h, m = match.groups() return time(int(h) % 24, int(m) % 60) def validate(self, raw_value: str) -> ValidationResult: raw_value = raw_value.strip() for pattern, parser in self.PATTERNS: match = re.search(pattern, raw_value, re.I) if match: t = parser(match) if t: return ValidationResult(is_valid=True, normalized_value=t) # 最终兜底:尝试用 dateutil(仅当其他都失败) try: from dateutil import parser as dtparser dt = dtparser.parse(raw_value, fuzzy=True) return ValidationResult(is_valid=True, normalized_value=dt.time()) except: return ValidationResult( is_valid=False, error_msg=f"时间格式 '{raw_value}' 无法识别,请说'下午三点'、'15:00'等标准说法" )这个解析器在我们食堂项目中覆盖了 99.2% 的用户时间表达,包括“快到三点了”(匹配到“三点”)、“等会儿送”(不匹配,触发澄清)。关键是它快——平均耗时 1.2ms,比调用外部 NLP 库快两个数量级,且完全可控。
3.3 地址槽位的标准化:为什么不能只靠正则,而要引入轻量地理编码
“送到3号楼前台” 是典型。正则r'(\d+)号楼(.*)'能抽到3和前台,但问题来了:
- 用户说“送到三号楼”,正则失效;
- “送到A栋1楼”,正则又得改;
- “送到行政楼东侧”,正则根本没法写。
我们的方案是分层处理:
第一层:规则抽取(Rule-based Extraction)
用 spaCy 训练一个极小的 NER 模型,只识别BUILDING(楼栋)、FLOOR(楼层)、AREA(区域)三类实体。模型只有 200 行标注数据,但 F1 达 0.91。它把“三号楼”标为BUILDING,“3号楼”也标为BUILDING,统一交给下一步。第二层:标准化映射(Standardization Mapping)
维护一张building_map.csv:input_pattern,standard_code,standard_name "三号楼|3号楼|第三教学楼","BUILDING_003","3号楼" "A栋|A座|A楼","BUILDING_A","A栋" "行政楼|办公楼","BUILDING_ADMIN","行政楼"用
fuzzywuzzy做近似匹配,把“三号楼”映射到"BUILDING_003"。第三层:地理编码补全(Geocoding Enrichment)
对BUILDING_003查询内部地理库,得到:{ "code": "BUILDING_003", "name": "3号楼", "coordinates": {"lat": 31.2345, "lng": 121.4567}, "floors": ["1F", "2F", "3F"], "default_delivery_zone": "前台" }这样,即使用户只说“送到3号楼”,系统也能补全默认区域;如果说“送到3号楼2楼茶水间”,则校验
2楼是否在floors列表中。
这套组合拳让地址槽位准确率从 68% 提升到 94%,且新增楼栋只需更新 CSV,无需重训模型。
4. 实操过程详解:从零搭建带 checkpoint 的订单填槽子图,附完整可运行代码
4.1 环境准备与依赖安装:为什么选 SQLite 而不是内存检查点
我们用 SQLite 作为开发期 checkpoint 存储,不是因为它多好,而是因为:
- 零配置:
pip install langgraph langchain-openai sqlite3后开箱即用; - 可调试:直接
sqlite3 checkpoints.db ".dump"查看每步 state,比在内存里 debug 强十倍; - 平滑迁移:生产切 PostgreSQL 时,只需改一行
SqliteSaver.from_conn_string("postgresql://..."),API 完全一致。
安装命令:
pip install langgraph langchain-openai openai python-dotenv psycopg2-binary # 开发期用 SQLite,生产期换 PostgreSQL pip install pysqlite3 # 如果系统 sqlite 版本太低.env文件内容:
OPENAI_API_KEY=sk-... LANGCHAIN_TRACING_V2=true LANGCHAIN_ENDPOINT=https://api.smith.langchain.com LANGCHAIN_PROJECT=voice-assistant-demo4.2 定义核心 State 与 Slot Schema:用 TypedDict 而不是 Pydantic 的理由
我们放弃 Pydantic,改用typing.TypedDict,因为:
- 性能:Pydantic v2 解析 1KB JSON 比
json.loads()慢 3 倍,而填槽节点每秒要处理上百请求; - 灵活性:TypedDict 支持
NotRequired,方便定义部分必填的槽位; - 无侵入:不强制所有字段都有 validator,有些槽位(如
notes)就是自由文本。
from typing import TypedDict, NotRequired, List, Dict, Any, Optional from datetime import time class OrderSlots(TypedDict): beverage: str # 必填 temperature: str # 必填 sugar_level: NotRequired[str] # 选填 delivery_location: str # 必填 delivery_time: time # 必填,已转为 time 对象 notes: NotRequired[str] # 选填 class VoiceAssistantState(TypedDict): messages: List[BaseMessage] slots: OrderSlots step: str # "collecting", "confirming", "submitting" last_user_input: str confirmed_slots: List[str] # 已确认的槽位名列表4.3 编写 fill_slots 节点:三步流水线的完整实现
from langgraph.graph import StateGraph, START, END from langchain_core.messages import SystemMessage, HumanMessage, AIMessage from langchain_openai import ChatOpenAI import json import re # 初始化 LLM(固定 temperature=0 保证确定性) llm = ChatOpenAI(model="gpt-4o-mini", temperature=0) # Step 1: LLM 感知 —— 获取候选槽值 def perceive_slots(state: VoiceAssistantState) -> dict: # 构造 few-shot prompt examples = [ ("我要一杯热美式,不加糖", '[{"slot": "beverage", "value": "美式", "confidence": 0.98}, {"slot": "temperature", "value": "热", "confidence": 0.99}, {"slot": "sugar_level", "value": "无糖", "confidence": 0.95}]'), ("送到3号楼前台,下午三点", '[{"slot": "delivery_location", "value": "3号楼前台", "confidence": 0.97}, {"slot": "delivery_time", "value": "下午三点", "confidence": 0.96}]') ] example_str = "\n".join([f"用户:{q}\n助手:{a}" for q, a in examples]) prompt = f"""你是一个订单槽位提取器,请从用户对话中识别以下槽位: - beverage(饮品类型,选项:美式/拿铁/摩卡/抹茶) - temperature(温度,选项:热/冰/常温) - sugar_level(糖度,选项:无糖/半糖/全糖/另说明) - delivery_location(送达地点,格式:X号楼Y层Z区域) - delivery_time(送达时间,口语化表达) 请严格按JSON数组格式输出,每个元素包含 slot、value、confidence(0.0-1.0)三个字段。 示例: {example_str} 当前对话: {state['messages'][-1].content} 只输出JSON,不要任何解释。""" response = llm.invoke([SystemMessage(content=prompt)]) try: candidates = json.loads(response.content) # 过滤掉置信度<0.7的候选(防噪声) candidates = [c for c in candidates if c.get("confidence", 0) >= 0.7] except Exception as e: candidates = [] return {"candidate_slots": candidates} # Step 2: 类型校验 —— 调用各 validator def validate_slots(state: VoiceAssistantState) -> dict: candidate_slots = state.get("candidate_slots", []) validated = {} warnings = [] errors = [] # 每个槽位的 validator 实例 validators = { "beverage": BeverageValidator(), "temperature": TemperatureValidator(), "sugar_level": SugarLevelValidator(), "delivery_location": LocationValidator(), "delivery_time": TimeValidator(), } for cand in candidate_slots: slot_name = cand["slot"] raw_value = cand["value"] if slot_name not in validators: continue result = validators[slot_name].validate(raw_value) if result.is_valid: validated[slot_name] = result.normalized_value if result.warning: warnings.append(result.warning) else: errors.append(f"{slot_name}: {result.error_msg}") # 合并到现有 slots new_slots = {**state.get("slots", {}), **validated} return { "slots": new_slots, "validation_warnings": warnings, "validation_errors": errors } # Step 3: 归一存储与 checkpoint 触发 def normalize_and_checkpoint(state: VoiceAssistantState) -> dict: # 1. 归一化:确保 time 是 time 对象,location 是标准码 slots = state["slots"].copy() if "delivery_time" in slots and isinstance(slots["delivery_time"], str): # 二次解析(以防 validator 没覆盖) t_val = TimeValidator().validate(slots["delivery_time"]) if t_val.is_valid: slots["delivery_time"] = t_val.normalized_value # 2. 写入业务 checkpoint(此处简化为打印,实际调用 DB) critical_state = { "session_id": state.get("session_id", "dev_session"), "slots_filled": {k: v for k, v in slots.items() if v is not None}, "step": state.get("step", "collecting"), "updated_at": "now()" } print(f"[CHECKPOINT] Saved: {json.dumps(critical_state, default=str, ensure_ascii=False)}") return {"slots": slots} # 组装 fill_slots 节点 def fill_slots_node(state: VoiceAssistantState) -> dict: # 三步流水线 state_after_perceive = perceive_slots(state) state_after_validate = validate_slots({**state, **state_after_perceive}) state_after_normalize = normalize_and_checkpoint({**state, **state_after_validate}) return state_after_normalize # 构建图 workflow = StateGraph(VoiceAssistantState) workflow.add_node("fill_slots", fill_slots_node) workflow.add_edge(START, "fill_slots") workflow.add_edge("fill_slots", END) app = workflow.compile()4.4 测试与调试:用真实对话流验证三步流水线
我们准备了 5 类典型测试用例,覆盖边界场景:
| 测试用例 | 输入对话 | 期望行为 | 实际输出(截取关键) |
|---|---|---|---|
| Case 1:标准输入 | “我要一杯热美式,不加糖,送到3号楼前台,下午三点” | 所有槽位正确填充,无 warning | {"beverage": "美式", "temperature": "热", "sugar_level": "无糖", "delivery_location": "BUILDING_003-前台", "delivery_time": "15:00:00"} |
| Case 2:模糊输入 | “来杯americano,冰的,送到A栋1楼” | 自动修正americano→美式,A栋→BUILDING_A | warning:"已将'americano'自动修正为'美式'" |
| Case 3:缺失槽位 | “我要一杯咖啡” | beverage填“美式”(默认),其他槽位为空,触发追问 | validation_errors: ["temperature: 温度是必填项..."] |
| Case 4:冲突输入 | “我要热美式” → “改成冰的” | 第二轮覆盖第一轮,temperature更新为“冰” | slots: {..., "temperature": "冰"} |
| Case 5:非法输入 | “我要珍珠奶茶” | 拦截beverage,返回错误提示 | validation_errors: ["beverage: 饮品类型 '珍珠奶茶' 不在可选范围内..."] |
调试技巧:在perceive_slots函数里加print("PROMPT:", prompt),把 prompt 复制到 ChatGPT 里手动测试,快速定位是 LLM 理解问题还是 prompt 写得不够清楚。
5. 常见问题与排查技巧实录:那些文档里不会写的坑,我们都踩过了
5.1 LLM 填槽准确率忽高忽低?先查这三处
问题现象:同一句话,第一次调用填对了,第二次调用就漏掉sugar_level,波动很大。
排查路径:
- 检查 temperature 参数:是否设为
0?如果temperature=0.3,LLM 会随机省略字段。我们曾因此浪费两天——把temperature误写成temp=0.3,LLM 认为这是新参数而忽略。 - 检查 prompt 中的 few-shot 示例:示例是否覆盖了目标槽位?如果所有示例都没出现
sugar_level,LLM 就倾向于不填。解决方案:每个示例强制包含 1-2 个选填槽位。 - 检查 messages 输入格式:是否把 system message 和 user message 混在一起?LangChain 要求
messages=[SystemMessage(...), HumanMessage(...)],如果传messages=["system...", "user..."]字符串列表,LLM 会当成普通文本忽略 system 指令。
实操心得:写个
debug_prompt()函数,把最终发给 LLM 的 prompt 打印出来,复制粘贴到 playground 测试。90% 的填槽问题,都能在 prompt 层解决。
5.2 自定义 validator 总是不触发?90% 是因为 state 字段名不匹配
问题现象:写了BeverageValidator,但validate_slots函数里validators["beverage"]报 keyerror。
根因分析:LLM 输出的 slot 名是"beverage_type",而你的 validator key 是"beverage"。LLM 有自由发挥权,它可能输出任意字段名。
解决方案:在perceive_slots后加一层slot name normalization:
# 在 perceive_slots 函数末尾添加 slot_name_mapping = { "beverage_type": "beverage", "drink": "beverage", "temp": "temperature", "time": "delivery_time", "where": "delivery_location", } for cand in candidates: orig_name = cand["slot"] if orig_name in slot_name_mapping: cand["slot"] = slot_name_mapping[orig_name]这个映射表要持续积累——上线后每天扫日志,把新出现的 slot 名加进去。我们三个月积累了 37 个别名,准确率提升 12%。
5.3 checkpoint 恢复后 bot 重复提问?因为你没重置 messages
问题现象:用户说“我要热美式”,bot 问“请问送到哪里?”,用户还没答就断线;恢复后 bot 又问一遍“请问送到哪里?”,用户崩溃。
本质原因:LangGraph 的invoke恢复时,会把 checkpoint 里的messages完整加载,而messages里包含了上一轮 bot 的提问。节点执行时,看到messages[-1]是 bot 的 question,就认为该继续等待用户回答,于是再次生成相同 question。
正确做法:在恢复 state 后,清空除 system message 外的所有 messages,只保留初始上下文:
def restore_state(session_id: str) -> VoiceAssistantState: # 从 DB 读 critical_state critical = get_critical_state(session_id) # 构造最小化 state return { "messages": [ SystemMessage(content="你是一个专业订单助手,请准确提取用户需求...") ], "slots": critical["slots_filled"], "step": critical["step"], "session_id": session_id } # 调用时 initial_state = restore_state("sess_123") result = app.invoke(initial_state, config={"configurable": {"thread_id": "sess_123"}})这样,恢复后的 first turn 就是 bot 主动发起新提问(如“好的,已记录热美式。请问要送到哪里?”),而不是机械重复。
5.4 生产环境 CPU 爆高?警惕 validator 里的正则回溯
问题现象:上线后 CPU 持续 100%,top显示 Python 进程占满核,但 QPS 很低。
诊断方法:用py-spy record -p <pid> -o profile.svg采样,发现 80% 时间在re.search。
根因:写了危险正则,如r'.*(美式|拿铁|摩卡).*',当用户输入超长文本(如带 URL 的备注),正则引擎会指数级回溯。
安全正则写法:
- 避免
.*在中间,改用[^ ]*或明确边界; - 用
re.compile()预编译,避免重复编译; - 对长文本先截断(如只取前 200 字符)再匹配。
# ❌ 危险 pattern = r'.*美式.*' # ✅ 安全 SAFE_MAX_LEN = 200 text = raw_value[:SAFE_MAX_LEN] if re.search(r'(美式|拿铁|摩卡|抹茶)', text): ...我们因此把所有 validator 的输入长度限制为 200 字符,CPU 从 100% 降到 15%,且未影响准确率——用户极少在一句话里说超过 200 字。
5.5 如何监控填槽效果?建立三维度健康看板
光跑通不够,要量化。我们在 Grafana 搭了三个核心指标:
| 指标 | 计算方式 | 健康阈值 | 异常响应 |
|---|---|---|---|
| Slot Fill Rate | SUM(slots_filled_count) / SUM(attempted_slots) | ≥ 95% | <90%:检查 LLM prompt 或 validator 覆盖率 |
| Auto-Correction Rate | COUNT(fuzzy_corrected) / COUNT(all_filled) | 5%-20% | >30%:说明用户表达习惯与标准值偏差大,需扩充 fuzzy map |
| Validation Error Rate | COUNT(validation_errors) / COUNT(all_inputs) | ≤ 5% | >10%:重点看 error_msg 分布,高频错误项优先优化 validator |
每天晨会看一眼这三个数字,比看日志高效十倍。有一次Validation Error Rate突然升到 18%,我们发现全是 `