LLM支持的AI Agent对话状态跟踪听起来像是一个只有算法工程师才会关心的黑话但只要你做过客服机器人、预订助手、企业办公Agent就一定被同一个问题折磨过用户聊到第三轮改了个时间改了个人数加了句备注Agent已经分不清哪些是旧需求、哪些是新需求最后给出一个驴唇不对马嘴的回复。我以前做传统任务型对话时解决这个问题要标注几千条对话、微调一个槽位填充模型过程非常痛苦。现在LLM把这件事的难度降了一个量级但随之而来的是一堆新的坑模型会乱补信息、会维护错误状态、会记不住早期槽位。这篇文章就围绕“LLM支持的AI Agent对话状态跟踪”这个话题把我近一年在多个项目里用下来的完整方案、踩坑记录和可以直接抄走的代码逻辑全盘托出。适合正在做对话Agent、想要把多轮交互做扎实的开发者、架构师以及想从提示词工程升级到完整状态管理体系的同学。1. 从传统DST到LLM一个模块的进化史1.1 为什么Agent必须有状态跟踪对话状态跟踪Dialogue State Tracking简称DST是整个对话系统里负责回答“用户到现在到底想要什么”的模块。它和维护一堆临时变量的内存不一样它要处理的是自然语言里那些不完整、不确定、有指代的信息。比如用户说“就按刚才那个方案吧”如果不知道“刚才”指的是什么这句话没有任何信息量。DST存在的意义就是把每一轮对话中用户表达出来的目标沉淀成一个结构化、可计算、可校验的语义状态。拿一个会议预订Agent举例用户说“帮我约一下小王和小李周三下午线上会议。”这时状态里就应当出现参与者[小王, 小李]日期周三时间下午形式线上。下一轮用户又补了一句“改成周五吧上午。”Agent如果维护了一个状态就知道这里“改成”指的是日期而不是参与者于是把日期更新为周五。如果Agent没有状态它可能以为用户想定制一套全新的会议需求把前一轮的信息全部丢掉。所以状态跟踪不是一个学术概念它是所有任务型Agent的“短期记忆”和“工作记忆”合体。没有这个模块Agent就只能做单轮问答一旦涉及多轮修正、追问、条件约束对话就会原地爆炸。这也是为什么我们在做任何需要完成真实任务的Agent时第一件事就是定义状态结构而不是先调提示词。1.2 传统方案的痛点与LLM的破局点在LLM普及之前DST的主流实现路径大体是两条。一条是基于规则和模板定义好槽位正则比如“日期”怎么抓、“城市”怎么抓但自然语言变化无穷规则只能覆盖最常用的表达一遇到口语、省略句、指代就失灵。另一条是训练专用的语义理解模型给槽位定义词表标注大量对话训练一个基于预训练语言模型的分类或抽取模型比如从“我想订明晚在望京的川菜馆”里抽出“时间明晚区域望京菜系川菜”。这条路效果不差但代价非常高换一个业务域就要重新标注、重新训练而且对少样本槽位、隐含信息的识别很弱。LLM带来的变化本质上是把DST从“训练专用模型”变成了“结构化抽取逻辑校验”的组合。LLM具备很强的零样本理解能力你可以直接把槽位定义和对话历史塞进去让它输出一个JSON格式的状态片段甚至可以要求它输出“更新指令”而不是整个状态。这样做的好处不止是省了标注成本更重要的是它可以处理传统模型很难搞定的情况比如用户说“明天有点悬可能改到后天”模型能理解“后天”是候选时间而“明天”可能作废这是规则几乎做不到的。但LLM也有自己的隐患。它容易被上下文误导会脑补用户没说过的话还会在长对话中互相矛盾。所以我现在做LLM状态跟踪核心思路不是“让模型自由发挥”而是“将模型当作一个抽取器配上强校验和状态合并逻辑”这比单纯写一条长提示词要可靠得多。2. LLM驱动的状态跟踪核心设计2.1 状态表示先想好槽位与Schema开始写提示词之前先花半天时间设计你的状态Schema这是我反复强调的一点。状态跟踪的质量上限不在模型而在Schema定义。你需要明确这个Agent到底需要跟踪哪些信息这些信息是单值还是多值是全局还是局部值域是开放还是枚举。比如做酒店预订Agent常见槽位包括目的地、入住日期、离店日期、房型、预算、协议价、入住人。这里面“目的地”是字符串“房型”是枚举大床房、双床房、套房“预算”可能是区间“入住人”可能是列表。这些格式必须在Schema里写清楚否则LLM输出的JSON就会一团乱。更关键的是你要区分哪些信息是“用户已确认”的哪些是“用户提到但未确认”的哪些是“系统猜测”的。以前传统DST里有“belief state”的概念就是维护一组带置信度的假设。到了LLM时代很多人把这个环节省了直接追问一句“你现在确定吗”结果用户漏说了某个盗版答案Agent就信以为真后面再想纠错就难了。我的建议是Schema里至少包含三层raw_mentions原文提及按原文记录、normalized_values规范化后的值用于后续业务调用、confidence_or_status状态是confirmed还是candidate还是unset。这样既保留了用户原话可以追查又给业务层一个可信的取值。另外如果有多个独立的子任务比如一个客服Agent既要处理退货、又要处理换货、还要处理发票那么状态不要混在一个扁平JSON里建议用子状态命名空间隔离比如return_request和invoice_request分开避免时区、金额互相覆盖。2.2 上下文组织哪些该进Prompt哪些不要进很多人在Prompt里堆了几千字的对话历史结果模型输出质量反而更差。LLM支持的DST需要仔细选择进模型的上下文。通常情况下我会把以下几部分组织在一起系统级别的状态提取指令、当前任务Schema及字段说明、上一轮状态快照、最近N轮对话、当前用户输入、少量示例。这里最重要的一点是“上一轮状态快照”必须给到模型而不是只给对话历史。因为状态快照是清晰的结构化信息模型只需判断哪些字段需要变化比从一堆对话文本里重新推导要稳定得多。举个例子上一轮状态是“日期周三”这一轮用户说“改成周五”模型看到快照再加一句对比很容易输出“日期周五”如果模型只看到原始对话它可能分不清“改成周五”是改日期还是改参与者。上下文长度也要控制。我采用“滚动窗口关键状态注入”的策略保留最近4到6轮完整对话更早的对话如果不影响当前槽位就直接放掉但所有已经确认的关键状态一定放在状态快照里这样即使旧对话被截断模型仍有全局信息。如果对话特别长比如超过20轮可以额外生成一个对话摘要把非槽位信息也保留下来方便Agent理解用户意图的演变。2.3 状态更新策略全量重写还是增量补丁传统DST一般维护一个概率分布矩阵LLM场景下有两种更新状态的方式。第一种是“全量状态重写”也就是每一轮都让模型根据全部对话重写整个状态。这种方式的优点是逻辑全局一致不容易漏掉旧的已确认槽位缺点是token消耗大而且模型偶尔会把一个不该变的槽位改掉造成状态抖动。第二种是“增量补丁更新”让模型只输出需要变更的字段。比如输出一个JSON数组里面是{slot: “date”, action: “set”, value: “2025-06-20”}或{action: “delete”}。这种方式成本低、可审计但最大的问题是模型可能漏掉需要清除的槽位。比如用户说“算了不订酒店了”增量模型可能只删了酒店名称却没删日期和城市。我在项目里通常用混合方案让LLM做增量更新输出“强制更新字段”和“待清除字段”两个列表然后用代码执行合并逻辑而不是让模型直接给出完整状态。合并逻辑里要区分“用户新说的”和“用户撤回的”。比如当检测到用户说“取消这个”时规则层直接把上一轮系统推荐的槽位清空这比等模型来输出delete更可靠。总之不管用什么策略都要保证状态更新是确定性的LLM只负责理解自然语言状态合并和冲突解决尽量用代码完成。2.4 与工具动作联动Agent状态不局限于对话很多Agent不是只靠对话就能完成任务的它要调API、查数据库、操作业务系统。比如一个订餐Agent聊完需求后要调餐厅预订接口接口返回“该时段已满”Agent就得把时段标记为不可用并主动询问用户换时间。这时候对话状态里需要加入“动作执行结果”这个维度。我习惯在状态Schema里增加一个meta字段专门记录当前任务阶段collecting、executing、confirming、done和最近动作的状态success、failed、partial。当工具调用失败时状态跟踪模块要能保留用户的目标但修正“已满足”的假设。例如用户想要的餐位时间不可用我们不能直接把时间槽位删除而是应该在状态里同时保留用户原始偏好和系统推荐替代时间把它标记为candidate。这样下一轮用户说“可以”Agent就知道了要替换成替代时间。所以Agent的状态跟踪本质上是一个“对话信息系统动作结果”的联合缓存。如果你的Agent会调外部工具请务必把工具返回的结果纳入状态计算否则前端展示的状态和真实业务状态完全对不上。3. 一个最小可用的实现Python LLM3.1 定义Schema与对话示例我用一个会议预订Agent作为例子它的功能是收集会议主题、时间、参与人、会议形式、会议室然后生成预订请求。状态Schema先定义为一个Python字典字段如下CONFERENCE_SCHEMA { topic: {type: string, multi_value: False}, date: {type: string, multi_value: False}, start_time: {type: string, multi_value: False}, participants: {type: list[str], multi_value: True}, form: {type: enum, values: [线上, 线下, 混合], multi_value: False}, room: {type: string, multi_value: False}, }为了给LLM一个可靠的输出约束我把Schema转换成提示词里的JSON描述同时给一个简版示例让模型知道输出格式长什么样。实际项目中如果对输出格式要求更严格可以用结构化输出的方式强制生成JSON不过就算没有也可以在解析层做兜底。3.2 构建Prompt模板下面这个模板是我在实际项目里简化后的版本。核心思想是先把状态快照和最近的对话传入要求模型输出增量更新指令。SYSTEM_PROMPT 你是一个对话状态更新引擎。 根据用户新的对话消息比较当前状态快照输出状态更新指令。 状态字段定义 - topic: 会议主题(string) - date: 日期格式 YYYY-MM-DD - start_time: 开始时间格式 HH:MM - participants: 参与人列表(list) - form: 会议形式只能是 线上/线下/混合 - room: 会议室名称 只允许输出两种操作set 表示设置或覆盖字段值delete 表示清除字段值。 更新指令格式必须是JSON list每个元素格式 {slot: 字段名, action: set/delete, value: 字段值} 如果当前用户消息没有携带任何可更新信息输出空数组: [] 注意不要从对话历史里猜测用户没有明确表达的信息如果用户修改了某个字段请同时给出新的值不要保留旧值。 def build_dst_prompt(state_snapshot, dialogue_history, user_input): history_text \n.join(dialogue_history[-6:]) snapshot_text json.dumps(state_snapshot, ensure_asciiFalse) return f 当前状态快照 {snapshot_text} 最近对话记录 {history_text} 用户本轮新输入 {user_input} 请输出状态更新指令这个模板的关键点在于“只让模型输出增量指令而不是要求它输出全部状态”这样当用户说“把张三改成李四”时模型输出set participants而我们旧状态里的其他参与者如果存在并不会被误删除合并逻辑会保留。如果模型直接给全量状态它很容易把之前多轮确认的其他参与者丢掉。3.3 状态解析、校验与合并逻辑模型输出后不能直接拿JSON去覆盖状态必须先解析、校验、再做合并。解析层要处理模型输出的各种不标准格式json代码块、多余的逗号、中英文括号混用。我习惯先提取JSON片段然后做一个简单修复再用json.loads如果没有就用正则兜底。校验层要验证值是否合法。比如date字段用datetime解析确认是一个真实日期enum字段要检查是否在允许列表里participants如果是sum类型必须逐项转成字符串。每次校验发现非法值时不能直接当作错误抛弃而是要把模型输出的原始内容记录下来并让状态维持为未变更同时打一条warning日志方便后续分析模型行为。合并逻辑如下def merge_state_updates(prev_state, updates, validators): new_state copy.deepcopy(prev_state) for upd in updates: slot upd.get(slot) action upd.get(action) if slot not in validators: continue if action set: value upd.get(value) validated validators[slot](value) if validated is not None: new_state[slot] validated elif action delete: new_state[slot] None else: continue return new_state注意set操作里对于list型槽位默认是全量覆盖还是增量添加这要在Schema里定义。会议参与者场景用户说“再把小李加进来”应当是在原参与者基础上增加一个而不是替换。所以凡是list类型字段默认允许追加只有当用户明确说“去掉张三”时才删除。我们在提示词里补充了这句list字段set表示覆盖整个列表如果想追加请使用actionappend。这里为了简化只用set和delete实际项目里我会加一个append操作来管理多值槽位。3.4 多轮对话联动测试写一个简单的循环来模拟多轮交互state {k: None for k in CONFERENCE_SCHEMA} messages [ 安排一个会议讨论季度复盘周五下午三点小王和小李参加, 改成周四上午吧小王的时间不合适, 地点就用会议室A线上还是线下先不定, 取消还没定的会议室吧先这样, ] dialogue_history [] for text in messages: prompt build_dst_prompt(state, dialogue_history, text) raw_output llm_chat(prompt) updates parse_json_robust(raw_output) state merge_state_updates(state, updates, validators) dialogue_history.append(text) print(state)第一轮输出状态里date应为周五start_time为15:00参与者为[小王, 小李]。第二轮用户说“改成周四上午吧小王时间不合适”合并逻辑应该是date变为周四start_time变为上午对应的09:00或一个笼统的上午同时参与者列表仍然保留[小王, 小李]因为用户没有说要移除小王只说时间不合适。但这里要小心LLM可能从这句话里推测“小王不能参加”从而把小王从参与者中删掉。这种推断是危险的因为你并不知道小王是被换掉还是时间改掉后就能参加。这时候校验逻辑里有一个规则除非用户明确说了“不要小王/把小王移除”否则不自动删人。规则应该优先于LLM输出。实际跑的时候你会发现模型往往会把“小王时间不合适”理解成“移除小王”。这就是我反复强调校验逻辑比模型理解更重要的原因。4. 对话状态跟踪的常见翻车现场与排查技巧4.1 状态漂移与幻觉槽位最让开发者头疼的问题是LLM会脑补用户没说的话。用户问“这个价格含税吗”LLM可能顺手把“发票要求”设置为“需要发票”这就是幻觉槽位。状态一旦被污染后面所有下游动作都会跟着错。解决这个问题的第一原则是提示词里明确要求“只能从用户本轮输入和最近上下文中抽取信息禁止推断未提及的槽位”。但仅仅靠提示词很难完全杜绝所以我还会引入一个“来源标注”机制要求模型输出的每个update都带上evidence字段即从哪句话提取的。如果没有evidence合并层直接忽略该更新。这个机制能过滤掉大部分幻觉。另外当模型不确定某些槽位时应该让它输出null而不是给一个猜测值。我们在Schema定义里明确每个字段都可以为null标志“未提及”。同时通过校验器阻止明显不合理的信息进状态。4.2 同义词归一与值规范化用户说“明早”系统状态里不能是“明早”业务层需要的是具体的日期和时间否则后续API调用会失败。我通常在状态跟踪后接一层“值规范化服务”。对日期时间用规则解析相对表达明早、后天、下周一、下午三点半。对地点、人名这类开放值很难直接归一我采取的做法是保留原始字符串同时映射到一个业务实体ID。比如用户说“皇冠酒店”经过实体链接后把它映射成某酒店的数据库ID这样后续查询不会因为匹配不到而挂掉。规范化和归一化不能交给LLM单独完成因为LLM在日期计算上容易出错。比如说“下周三”模型可能根据当前日期算错。时序表达最好用专门函数处理LLM只负责识别“下周三”是一个相对日期而解析和计算由代码完成。4.3 长对话中的遗忘与状态漂移长对话里LLM的上下文窗口有限滚动窗口丢了早期信息状态快照虽然能保留槽位但会把“早期用户说时还愿意、后来用户反悔”这种状态变化覆盖掉。更严重的是模型在长段历史中容易前后矛盾。比如第2轮用户设置了“预算5000以内”第15轮用户说“预算有点紧”模型可能会把预算改成“没提”但实际应该保持5000以内且追加一个“用户说紧”的备注。我采用的解决方案是给状态加一个“置信度衰减”机制当会话长度超过一定值新输入对旧槽位的修改必须显式表达“确实要改”。如果用户只是表达了情绪或犹豫状态里的核心槽位保留另外把用户最新的不确定性写入note字段。这个note字段不进业务操作但会提示Agent下一步澄清避免误操作。如果对话非常长超过模型有效处理能力我建议在状态跟踪模块前加一个“对话摘要Agent”每5轮生成一个摘要并把摘要作为新历史的第一条消息参与后续的DST。摘要应包括用户提出的所有请求、已确认的槽位、未确认的槽位、需要澄清的点。这样DST即便看不到早期完整对话也能从摘要中拿到关键信息。4.4 多用户、多任务状态隔离一个真实的Agent可能同时为多个用户服务也可能一个用户在一次会话里同时咨询多个任务。很多人用一个全局state字典存所有内容结果用户说“刚才说的退货运费我再确认一下”Agent分不清这是哪个任务的状态。我用两层结构解决外层是session_id内层是task_id每个task有独立的state。对话进入时先由意图分类器确定当前任务再把DST限制在当前任务的Schema上。状态更新时只更新当前任务对应的namespace。跨任务引用时比如“刚才那个退货单”需要触发一个实体指代解析模块找到之前任务里对应的状态而不是直接把所有任务状态拼在一起喂给DST。4.5 成本与延迟控制LLM状态跟踪每轮都要调用一次模型成本不低。我这边有三个降本手段。第一个是缓存复用如果用户本轮输入只是一个简短确认词比如“嗯”“可以”“好的”并且没有槽位变化那可以不调用LLM直接合并到当前状态即可。可以用一个规则层前置判断检测到无信息量的回复就直接跳过LLM调用。第二个是压缩上下文把对话历史精简为摘要最近2轮而不是每次都传全部历史既省token又减少噪音。第三个是利用小模型做初筛当你不太依赖复杂推理时可以先用一个准确的轻量级NLI模型判断“本轮是否包含槽位更新信息”只有包含时才调用大模型。这样约30%-40%的轮次可以省掉大模型调用。对延迟敏感的场景还可以修改提示词让模型以流式方式先输出更新指令再接续输出其他内容不过这个优化通常要配合工程架构做异步更新。5. 效果评估与线上迭代5.1 评估指标别只用准确率DST的经典评估指标是Joint Goal Accuracy即整个状态的每一对槽位值都正确才算这一轮正确。这个指标严格、好用能直观反映状态是否完全对齐。但LLM场景下还需要补充两个指标更新指令正确率专门看本轮set/delete动作是否正确和状态稳定度连续两轮状态没有不相关变动。因为LLM输出的状态经常出现“虽然整体正确但某一轮把无关字段误改”的情况仅靠Joint Goal看不出这个问题。我建议在评测集里特意加入这些场景用户反悔、槽位删除、同义改写、指代消解、长对话30轮、多任务混合。每轮记录预测状态和金标准状态统计每个槽位的精确率和召回然后按错误类型聚类快速定位Prompt需要修正的地方。5.2 错误分析与Prompt迭代闭环上线初期我每天都会把线上日志里的DST预测与用户最终确认结果做对比。如果用户最后说“对就是这样”那预测的状态基本可视为成功。如果用户后来又修改了一个槽位说明之前预测很可能错了。把这些badcase收集起来每天跑一个聚类脚本将错误分为几类日期解析错、同名实体捣乱、模型幻觉、schema定义不清。针对每种错误做不同的修正。如果是schema定义不清就修改字段描述增加正反例如果是模型幻觉就在提示词中强调来源标注如果是实体归一问题就在归一化层加规则。这个闭环我踢了大概三周之后状态跟踪的准确率从84%提到95%全靠badcase反哺而不是单纯换更贵的模型。5.3 日志追踪状态快照是Debug的第一现场所有DST请求必须记录完整的日志本轮输入、上轮状态、模型原始输出、解析后的更新指令、合并后的新状态、校验器告警。这样当线上出现状态错乱时不用猜测直接从日志里复盘。我一般用三层日志debug记录详细信息info记录状态变更摘要error记录解析失败、校验失败。颗粒度要够细宁可日志量大也不要出了bug找不到原因。此外要给每个状态变更分配一个版本号或trace_id方便业务侧回滚和追责。如果某次状态更新造成下游动作错误可以快速定位是第几轮哪条指令导致的。6. 个人实操中的三点体会写了这么多年对话系统我最大的体会是LLM支持的DST并不是“让LLM做DST”而是“用LLM提升DST中的语言理解部分剩下的交给工程”。把状态合并逻辑、值校验、规则兜底、日志追踪都保留在代码层模型只负责从自然语言中抽取结构化增量。这样即使用户表达再刁钻状态也不会被模型的一顿输出带跑偏。第二点体会是关于Prompt的不要追求一次把Prompt写到完美而是先写一个能跑的版本然后把评测集里反复出现的错误一条条加进示例中。比如我发现模型经常把“改成周五”理解成主题变化于是在fewshot里加了一个用户的澄清示例效果立竿见影。LLM时代的DST优化更像是在维护一个知识库而不是调参。最后一个实用建议当你拿到一段新的对话场景不要急着写代码先手工模拟十几轮对话把每一轮你期望的状态变化写出来再把这个状态变化序列作为测试用例。这些测试用例会时刻提醒你状态跟踪的目标不是让模型夸夸其谈而是让Agent在每一个真实业务决策点都拥有可靠、准确、可审计的记忆。哪怕LLM版本再升级这个底线也不会变。