我最早做对话型Agent的时候,吃过一次很没面子的亏。用户在订餐助手那边连续说了两句:帮我订周五晚上8点,三个人,市中心老牌粤菜,然后紧接着补了一句提前一小时吧。助手直接懵了——因为它把第二轮当成了一次全新的请求,不知道要改的是“晚上8点”,更不知道“三个人”和“市中心”这些信息还要接着用。模型能力是够的,缺的是最基础的那个组件:对话状态跟踪(Dialogue State Tracking,DST)。
后来我发现这不是个别问题。凡是做LLM支持的多轮AI Agent,只要涉及“用户说了什么、还差什么、下一步要不要继续问”,背后都绕不开状态跟踪。用LLM来做这件事,已经成了任务型Agent的标准姿势,但真正把它做稳,比想象中要花更多心思。这篇文章我会从传统DST的痛点说起,给出一套用LLM实现状态跟踪的完整方案:状态外置的思路、提示词和Schema怎么设计、一个最小可复现代码,以及我在真实项目中踩过的四个坑和对应的处理方式。适合正在搭客服、订票、电商导购这类任务型Agent的开发者参考。
1. 多轮对话里的"记性"问题:状态跟踪到底在跟踪什么
先别急着上代码,把底层的概念说清楚。很多人把Agent的“记忆”理解成“把历史对话塞进上下文”,然后让模型自己看着办。这其实是两码事。把历史原文塞进去,模型能做一定的“临时记忆”,但它没有精力在每一轮都从整段原文里梳理约束条件,也容易漏。状态跟踪的核心任务,是把多轮对话里的用户意图约束抽出来,维护成一个结构化状态——通常是JSON,字段叫槽位(slot),值为用户在这轮对话中给出的意图参数。
1.1 一次典型的"失忆"对话
我们用同一个订餐场景走一遍,看状态是怎么变化的:
- 用户:
帮我订周五晚上8点,三个人,市中心。 - 助手:
好的,周五晚市中心,三位。还需要选一家餐厅。 - 用户:
如果有包间就更好了。 - 助手:
请问包间对预算有要求吗?还是都可以? - 用户:
两个人安静一点的,时间改成七点吧。
站在状态跟踪器的视角,这四轮的状态变化应该长这样:
| 轮次 | 日期 | 时间 | 人数 | 区域 | 其他 |
|---|---|---|---|---|---|
| 1 | 周五 | 20:00 | 3 | 市中心 | 粤菜 |
| 2 | 周五 | 20:00 | 3 | 市中心 | 粤菜 |
| 3 | 周五 | 20:00 | 3 | 市中心 | 粤菜、包间 |
| 4 | 周五 | 19:00 | 2 | 市中心 | 粤菜、包间、安静 |
最后一轮里“两个人安静一点的,时间改成七点吧”这句话信息非常密集:它把人数从3改成2,新增了“安静”这个约束,又把时间从20:00改成19:00。如果Agent没有状态跟踪,到第三轮开始就会彻底迷失——它没法知道“包间”是给同一组人加的约束,也不知道“改成七点”改的是哪个时间。这不是推理能力的问题,而是状态维护的机制根本不存在。
1.2 状态跟踪的真正作用
在我看来,状态跟踪在对话型Agent里至少承担了三层职责。
第一是结构化记忆。把自然语言变成槽位值,例如从周五晚上8点中抽取{date: "周五", time: "20:00"}。之后模型做策略决策、查数据库、调用工具时,直接吃结构化状态,而不是去翻阅原始对话。工程上,结构化状态好存储、好校验、好调试。
第二是支持多轮更新。Agent从“无状态”变成“有状态”,用户才能中途改主意、补充细节、用代词指代之前的内容。否则每轮都是一次从零开始的盲猜。
第三是驱动对话策略。Agent下一步到底该问什么,取决于状态还缺什么。如果“日期”“人数”“区域”这些必填槽位没收集齐,最优策略是澄清式反问,而不是硬着头皮执行。这个“完成度”判断,本质上就是基于状态做的:哪些槽位已确认、哪些仍为空、哪些是候选值。
理解到这一步,就明白为什么LLM的出现会改变这一整块的做法了。不是推荐算法变了,而是“状态到底怎么从对话里提出来”这件事,从训练专用模型变成了一次性写提示词。
2. 传统方案为什么在这条路上步履维艰
讲LLM方案之前,还是得清楚旧方案死在哪里。不是旧方案没人用,而是它们各自都有无法绕开的硬伤。
2.1 规则与统计模型几乎是"管道工"打法
最早的DST是纯规则。正则表达式加模板提取,比如时间槽位可以写:匹配\d+点拿小时,匹配晚上/下午/上午拿时段,匹配明天/后天/周五拿日期。听上去还算能跑,但用户一旦说“不是,我说的是下周那个周四”,规则直接崩。因为真实对话里的实体表达千奇百怪,没法预先穷举。
统计时代的主流做法是意图识别加槽位填充。先判断用户这句话在改哪个槽位,再抽取槽位值。这比规则好,但本质还是在做“管道工”的活:每个槽位要训练一个抽取器或一个分类器,槽位一多,管道就变得又长又脆。生成式模型后来把“输出槽位序列”当成一个条件生成任务,减少了管道组装的工作量,但数据依赖一点没少,甚至更重。
2.2 三座大山:数据标注、Schema泛化、语言多样性
传统方案真正的天花板,集中在三个层面。
数据标注是第一个瓶颈。DST要训练得好,依赖大规模高质量多轮标注语料。每一轮对话都要标出“当前状态是什么”,标注成本非常高。换一个业务领域,旧的标注基本作废,又得重新造一轮数据。
Schema泛化是第二个瓶颈。业务上新加一个槽位,比如“是否要有停车位”,传统方案意味着要重新标注、重新训练;从一个领域迁到另一个领域,基本等于重来。这在实际项目里非常致命,因为业务需求永远是动态变化的,不可能为每次改动都排一个训练周期。
语言多样性是第三个瓶颈。真实对话里有省略(“提前一小时吧”省略了主语和对象)、指代(“那里”指代上一轮提到的餐厅)、修正(“不对,还是换一家”)、嵌套约束(“市中心靠地铁站的粤菜”)等等。传统方案每处理一种现象,就要增加一份规则或一批训练样本,永远在追赶。
2.3 LLM带来的范式转换
LLM出来之后,最大的变化不是“模型变聪明了”,而是“任务的边界被融化掉了”。DST从“为每个领域训练专用模型”,变成了“给一个通用模型描述清楚:你是谁、槽位有哪些、看到对话输出什么状态”。三个层面的变化非常直观:
- 零样本与少样本理解。提示词里放一两个输入输出示例,模型就能照着执行,不依赖重新训练。
- Schema热更新。新增槽位,改JSON Schema和提示词,马上生效。
- 架构更简洁。同一个LLM可以同时承担状态跟踪、策略判断、回复生成,不需要单独部署一个专门的DST模型。
但要清醒,LLM也会梦游。它可能输出不存在的槽位、遗忘旧值、输出非法JSON。我见过太多Demo第一天跑得乖乖的,第二天就被用户一句口语怼崩。所以接下来的主题是:怎么在工程上把LLM的状态跟踪能力约束住,让它稳定干活。
3. 可落地的实现:状态外置、每轮重估
在正式项目里,我用的核心思想是八个字:状态外置、每轮重估。状态本身不放在模型参数里,而是放在一个独立的JSON对象里;每一轮对话结束后,让LLM基于“旧状态快照+最新用户输入”重新评估,生成新状态。这个框架是当前工程上的主流做法,下面拆开讲。
3.1 状态外置的核心思想
为什么状态要外置,而不是让模型自己“记住”?因为大模型的参数记忆是“史诗级难”的,你没法保证它在第10轮还能准确回忆起你在第1轮说的那个地点。工程上可控的做法,是把状态独立出来,每一轮都显示化地交给模型。流程是这样的:
- 读取当前状态快照。
- 获取最新一轮用户输入。
- 组装提示词:角色定义 + Schema + 状态快照 + 用户输入。
- 让LLM输出新的完整状态JSON。
- 程序做Schema校验、类型约束、非法值过滤。
- 把更新后的状态写回存储。
这样做有一个额外好处:你可以随时查看“Agent当前认为用户想要什么”,这对调试和日志复盘太重要了。出了问题,把状态快照和提示词打出来,问题一目了然,而不是对着黑盒猜。
3.2 槽位Schema先于一切设计
动手写提示词之前,先把业务的状态结构定义清楚。我习惯用JSON Schema来描述,因为它既能直接嵌入提示词,又能在代码里做程序化校验。
{ "type": "object", "properties": { "people": {"type": "integer", "minimum": 1}, "date": {"type": "string"}, "time": {"type": "string"}, "area": {"type": "string", "enum": ["市中心", "东区", "西区"]}, "cuisine": {"type": "string"}, "quiet": {"type": "boolean"}, "private_room": {"type": "boolean"} }, "required": [] }这里有三条设计经验值得说。第一,单值槽位和多值槽位要区分。比如“区域”可能是单值,但用户说“市中心或者东区都行”,这时区域就变成多值。如果业务允许这种表达,我建议把字段设计成数组,并在提示词里写明“用户提供多个选择时输出数组”。第二,缺失的槽位在JSON里是“不出现”,还是输出null,要在提示词里说清楚,否则模型会输出一堆null干扰校验。第三,枚举值的候选口径直接影响LLM的匹配难度。给“市中心、东区、西区”这类自然值,比给“area_01、area_02”这种代号更容易对齐。真实业务里,一个映射表往往是写好Schema的必要前置工作。
3.3 提示词模板的写法与关键约束
提示词是状态跟踪里最容易被低估的部分。不是随便把Schema丢给模型就行,而是要明确告诉它几件事。我常用的模板长这样:
你是对话状态跟踪器。 任务:根据最新对话更新订餐意图状态。 必须遵守: 1. 只输出JSON,禁止输出任何解释。 2. 未提及的槽位必须保留原值,不要清空。 3. 新值与旧值冲突时,以新值覆盖旧值。 4. 无法确认的值不要写入,保持原值或留空。 5. 只允许使用Schema中定义的字段。 Schema: {JSON Schema} 当前状态: {JSON 状态快照} 用户最新输入: {utterance} 输出新的完整状态JSON:逐条拆解这些约束的用意。
“只输出JSON”是为了方便程序解析,减少和模型来回扯皮。现在主流的对话式大模型API基本上都支持JSON模式或工具调用模式,能开启尽量开启,稳定性会好很多。
“未提及的槽位必须保留原值”是针对状态漂移的关键约束。后面会细讲,这里先说结论:全量重写模式下,模型很容易把没提到的槽位清空,这一条能显著降低概率。
“新值覆盖旧值”是应对用户中途改主意的规则。没有这条,模型可能把新旧两个值同时保留,导致状态错乱。
“无法确认的值不要写入”是防幻觉用的。用户没说价格范围,模型不能自己脑补一个“适中”填进去。
3.4 一个最小可用的状态跟踪器实现
代码层面,我建议先搭一个最小实现跑通全流程,再根据业务往上加逻辑。下面这个类是我在多个项目里的起点,逻辑不复杂,但包含了核心链路:组装提示词、调LLM、解析JSON、Schema清洗、状态合并。
import json import copy from typing import Any, Dict class LLMStateTracker: def __init__(self, client, schema): self.client = client self.schema = schema self.state = {} def _clean(self, raw: Dict[str, Any]) -> Dict[str, Any]: props = self.schema.get("properties", {}) cleaned = {} for key, prop in props.items(): if key not in raw: continue value = raw[key] enum = prop.get("enum") if enum and value not in enum: continue if prop.get("type") == "integer": try: value = int(value) except (TypeError, ValueError): continue if prop.get("type") == "boolean" and not isinstance(value, bool): continue cleaned[key] = value return cleaned def update(self, utterance: str) -> Dict[str, Any]: props_json = json.dumps(self.schema, ensure_ascii=False) state_json = json.dumps(self.state, ensure_ascii=False) prompt = f""" 你是对话状态跟踪器。 任务:根据最新对话更新订餐意图状态。 必须遵守: 1. 只输出JSON,禁止输出任何解释。 2. 未提及的槽位必须保留原值,不要清空。 3. 新值与旧值冲突时,以新值覆盖旧值。 4. 无法确认的值不要写入。 5. 只允许使用Schema中定义的字段。 Schema: {props_json} 当前状态: {state_json} 用户最新输入: {utterance} 输出新的完整状态JSON: """ raw = self.client.chat([ {"role": "system", "content": "你是可靠的状态跟踪器。"}, {"role": "user", "content": prompt} ]) try: candidate = json.loads(raw) except json.JSONDecodeError: start, end = raw.find("{"), raw.rfind("}") if start == -1 or end <= start: # 解析失败,保持原状态,不更新也不清空 return copy.deepcopy(self.state) candidate = json.loads(raw[start:end+1]) cleaned = self._clean(candidate) # 模型没提到的槽位,用旧值兜底 for key in self.state: if key not in cleaned: cleaned[key] = self.state[key] self.state = cleaned return copy.deepcopy(self.state)这里的client是一个抽象的聊天客户端,你在真实项目里替换成自己用的模型服务SDK即可,只要能接收messages列表并返回字符串。_clean方法承担Schema校验:不在Schema里的字段丢弃、枚举范围之外的丢弃、类型不匹配的丢弃。最后一步用旧值兜底,是防止模型漏输出的关键,也是状态漂移的第一道防线。
用一段伪代码跑一下主循环:
class StubClient: def chat(self, messages): user_content = messages[-1]["content"] if "提前" in user_content: return '{"people": 3, "time": "19:00", "area": "市中心"}' return '{"people": 3, "time": "20:00", "area": "市中心"}' schema = { "type": "object", "properties": { "people": {"type": "integer"}, "time": {"type": "string"}, "area": {"type": "string", "enum": ["市中心", "东区", "西区"]} } } tracker = LLMStateTracker(client=StubClient(), schema=schema) print(tracker.update("帮我订周五晚上8点,三个人,市中心")) print(tracker.update("提前一小时吧"))第一轮输出{"people": 3, "time": "20:00", "area": "市中心"},第二轮输出{"people": 3, "time": "19:00", "area": "市中心"}。注意第二轮模型没有输出people和area,但代码里旧值兜底让它们保留了下来。这就是“状态外置+每轮重估”的最小闭环。
3.5 全量重写与增量合并:两种更新策略的取舍
上面示例用的是“全量重写”,即每轮都让LLM输出完整的新状态。与之相对的还有“增量合并”,即只让LLM输出状态变化,然后由代码负责合并。两者各有适用场景,我做了个对比:
| 维度 | 全量重写 | 增量合并 |
|---|---|---|
| Token开销 | 每轮输出整个状态,较大 | 只输出变化字段,较小 |
| 实现难度 | 低,适合快速跑通 | 稍高,要处理覆盖和删除语义 |
| 状态稳定性 | 中,容易漂移 | 高,未提及槽位天然保留 |
| 适用场景 | 槽位少、Demo阶段 | 槽位多、生产环境 |
我的建议是:新项目先全量重写快速跑通,把业务逻辑验证好;上线前看日志里状态漂移多不多,如果多,再切增量合并。增量合并也不是没有坑。最典型的一个是“删除操作”。用户说“不要包间了”,你让LLM输出变更时,它需要表达“把private_room置空”。我见过有人没设计这个语义,结果状态里的“包间”永远删不掉。所以增量模式下,必须在提示词里明确规定:清空某个槽位时,输出{"private_room": null},然后代码层把null处理成真正删除该键。
4. 翻车现场最集中的四个场景
这部分是我最想写的。光有方案和代码还不够,真实对话里的坑,大多藏在细节里。下面四个场景,每一个我都付出过真实调试成本,希望能帮读者少走弯路。
4.1 状态漂移:确认过的槽位被模型悄悄清空
现象很好识别:用户第一轮说四个人,后面几轮一直说时间、地点,并没有改人数,但某轮之后状态里的people字段突然消失了。我最早遇到时一度以为是幻觉,后来发现是机制问题。
根因在于全量重写模式下,模型看到最新一句只改了时间,就倾向于输出“这一轮涉及到的状态”,而把没提到的字段清空。提示词里如果没有强约束,这个倾向会被放大。我当时是这样一步步定位并解决的:
- 复现出错对话,单独把出错那一轮的提示词打出来看,确认输入组装没毛病。
- 在提示词里加入“未提及的槽位必须保留原值”。
- 加了还是偶尔漂移,于是加代码层的兜底:模型输出没包含的键,用旧值填上。
- 最后在Schema校验里给关键槽位加合理性检查,比如
people必须是正整数,area必须在枚举里,不合格直接拒绝更新。
这条链路走完之后,状态漂移基本被压到了极低的概率。记住一个原则:不要指望提示词一次写对,代码层的兜底才是可信赖的底线。
4.2 指代消解:"改成明天吧"到底改的是什么
用户说“改成明天吧”,如果是孤立的一句,任何模型都猜不到“明天”要改的是哪个槽位。但如果把旧状态快照一起喂给模型,信息就完整了:状态里已经有date字段,模型自然知道“明天”是在更新日期。
从设计角度说,提示词里的“当前状态快照”不是装饰,它是指代消解的锚点。我还习惯把最近两轮原始对话拼进上下文,因为有些指代光有状态快照还不够。比如用户说“那家不行”,快照里只有日期人数,没有餐厅名,这时必须往前看原文“帮我订那家老牌粤菜”,才能知道“那家”指代的是粤菜馆。
另一种更微妙的指代是“整单重置”。用户说“那算了,不订了,下周六再说吧”,这应该清空大部分槽位,而不是继续保留周五的信息。模型有时候会傻乎乎地保留老状态,再把日期改成下周六。我处理的办法是,在提示词里加一条:“当用户表达取消、重置或更换主题时,清空所有不相关的槽位”,然后配一两个少样本示例做巩固。
4.3 用户改主意:覆盖、并存还是一键回退
用户改主意有三种情况,处理方式完全不同。
第一是覆盖。先说A后说B,B覆盖A。比如“早上出发”改成“下午出发”,状态里time直接用新值覆盖就行。这个简单,提示词里那句“新值覆盖旧值”就是为它准备的。
第二是并存。用户说“市中心或者东区都行”,这对业务来说是两个合法候选,需要area字段支持多值数组。这个要在Schema设计的前置阶段想好,别等到上线了才发现字段类型要改。
第三是回退。用户先说A,再改B,后来又喊“还是A吧”。如果状态里只存最新值,回退逻辑就没法做。我的做法是对需要支持回退的槽位,在状态结构之外维护一个“值变更历史”,记录每个槽位的时间戳和值。回退时查历史,而不是让模型猜。本质上,这种复杂度已经超出纯DST范畴了,更像带时序的槽位管理,但在真实Agent里非常常用。
4.4 长会话下的上下文膨胀
对话超过十几轮后,把全部原文塞进上下文既不经济,也容易让模型注意力涣散。我采用三级策略:
- 滑动窗口:只保留最近3到5轮的原始对话文本,更早的用状态摘要替代。
- 状态摘要:当前状态JSON本身就是最好的历史压缩,关键信息在状态里都有。
- 持久化:对跨会话也重要的关键事实,比如用户过敏原、预算上限、联系方式,单独存到数据库,下次会话开始时重新加载进状态。
有一类信息很难被压缩进槽位,比如“用户刚才明显对上一条回复不满”。这种倾向性、情绪类的信息,会在广义状态跟踪里需要额外的字段来承载,比如attitude、complain。我建议在业务需求允许时,让LLM额外抽取这类“软状态”,帮助Agent调整话术,效果会好很多。但这也意味着提示词变复杂,需要更多调试,别一上来就全上。
5. 拿什么判断状态跟踪做得好不好:指标、对抗测试与兜底策略
最后聊聊质量保障。状态跟踪是个容易被忽视质量问题的模块,因为它在中间层,用户不会直接看到。但它的质量直接决定下游体验。
5.1 三个最常用的自动化指标
自动化评测我基本只看三个指标。
联合目标准确率(JGA)是最严格的一个:每一轮,所有槽位都预测正确,才计为1;任何一个槽位错了,整轮就算错。它很符合用户体感——用户关心的是系统有没有全部理解对。
槽位级准确率则是按单个槽位统计正确率,方便定位短板。比如area一直抽不对,那问题大概率出在Schema的枚举口径上,而不是整体模型能力。
我会额外关注状态完整率,也就是“用户提到过的信息里,有多少被正确捕捉进状态”。这个指标偏召回率视角,能发现“模型漏信息”的问题。JGA严格但无法告诉你到底漏了什么,所以三个一起看。
5.2 对抗性样本测试的做法
平时开发时,开发人员用的测试对话往往太规整了。真实用户会说出“8点晚上”“就那个时间往前挪一下”“三个人啊不对,两大一小”这种话。我给自己的测试集建设提了三条路:
- 从真实对话日志里捞失败样本,这是含金量最高的。
- 人工构造语言变体:把“周五晚上8点”改写成“周五8点”“这周周五的八点”“晚上八点,周五”——每种表达都要测。
- 让LLM辅助生成困难样本:给它一条标准表达,让它生成10种口语化说法,再人工去重和标注。这个办法能用较低成本把测试集铺开。
批量回归脚本也很简单:
def evaluate(test_cases, client, schema): tracker = LLMStateTracker(client, schema) correct = 0 total = 0 for dialog in test_cases: tracker.state = {} for utterance, expected in dialog: pred = tracker.update(utterance) total += 1 if pred == expected: correct += 1 print(f"JGA: {correct / total:.3f}")每次改提示词、改Schema,都把这套回归脚本跑一遍,前后对比数字,改质量就有据可依了。
5.3 兜底链路:解析失败、Schema违规与不确定时的澄清
LLM的稳定性再高,也要为坏情况设计兜底。我的兜底链路是分层的。
第一层是JSON解析失败。先重试一次,再失败就保持原状态不更新,同时返回一个“状态未变化”的标志。如果连续N次失败,Agent应该主动说“我没太听清,你能再说一遍吗”,而不是静默地假装成功。
第二层是Schema违规。枚举值不对、类型不对、数值范围不对,直接丢弃这一项,而不是整个状态都接受。有些值比如people: 0,虽然通过了JSON解析,但业务上明显不合理,这类业务规则最好也放进校验层。
第三层是低置信度场景。严格来说,LLM不直接给出置信度分数,但可以通过“必填槽位缺失”来做触发条件。当状态里必填槽位还是空的,Agent就进入澄清模式:问一个最关键的缺失项,而不是同时问十个问题。这个策略虽然朴素,但很有效——与其让模型猜着执行,不如宁可多问一句。
我在实际项目里还有一个习惯,就是定期抽查原始对话日志,把每轮喂给LLM的提示词和输出存下来,随机抽几十条逐条看。这套动作听起来原始,但两个小时就能帮我把状态跟踪的整体质量拉高一大截。配合回归测试脚本,基本能保证改动Schema或者提示词之后,旧的正确行为不被破坏。
如果你正在搭对话型Agent,我建议先把上面的最小实现跑通,替换成自己的业务Schema,再去解决那些边角场景。大部分问题都集中在指代消解和状态漂移上——遇到它们,回到第四章的排查思路,大概率能少走不少弯路。