把"填槽"两个字放进大模型时代,很多人问的第一个问题是:这不就是实体识别吗?其实差别非常大。我在参与某智能客服平台重构时,产品同学丢过来一条线上对话记录,用户上来就说:"帮我订两张明天晚上七点的电影票,要前排靠中间的位置。"粗看只是普通订票,但真要把这句自然语言转换成下游订单系统能用的结构化参数,你需要解决的远不止是抽出一两个实体。
这就是大模型填槽要处理的核心问题。填槽本来就是任务型对话系统里的老概念,指的是把用户自然语言里分散的信息填进预先定义好的字段中,比如日期、数量、影院、座位偏好;而"意图"则是用户这整句话想完成的任务,订票、退票、改签都算不同意图。过去大家靠规则和分类模型做这件事,现在换成了大模型,做法、坑位和工程边界全都变了。这篇文章我想从一个实际落地的视角,把大模型如何理解并提取用户意图这件事讲透,包括三条主流实现路线、多轮对话里的槽位管理、典型幻觉问题,以及一个可以直接照着改的电影票预订填槽案例。
1. 填槽到底在填什么:先理清这个问题的边界
1.1 一次订票对话里,槽位是怎么被定义的
上面的用户话术看似简单,但真正落地时要拆的东西比想象中多。"两张"是人数,"明天晚上七点"是日期和时间,"前排靠中间"又拆成排数和列数偏好两个独立维度。如果用户追加一句"第二张票换成靠过道",你面对的就是列表型槽位里某个元素的修改,而不是简单的 key-value 覆盖。
在做任何模型接入之前,我习惯先把槽位 Schema 定成一张明确的表,这比急着调 Prompt 重要得多。因为模型再聪明,也需要一份无歧义的字段说明,才知道该往哪里填、什么算合法值。订票场景的 Schema 通常长这样:
| 槽位字段 | 类型 | 示例 | 是否必填 | 备注 |
|---|---|---|---|---|
| movie_name | string | 星海之战 | 否 | 用户可能没说片名 |
| show_date | date | 2026-05-20 | 是 | "明天"要被换算成具体日期 |
| show_time | time | 19:00 | 是 | |
| cinema | string | 某影城 | 是 | 需要和业务库对齐 |
| seat_count | integer | 2 | 是 | |
| seat_area | string | 前排靠中间 | 否 | 可能拆成两个子字段 |
| contact_phone | string | 13800000000 | 取票时 | 需要格式校验 |
这张表定义好了,"填槽"这件事就有了明确边界:模型要做的,就是把用户语句里的信息放到对应字段里,同时判断出缺失的必填槽,留给下一步对话管理去追问。
1.2 传统 NLU 做了几十年,为什么还需要大模型来填
早年的对话系统,填槽是一条清晰的流水线:先有一个意图分类模型判断用户想做什么,再有一个命名实体识别模型抽取时间、地点、数量,然后对话状态跟踪模块把结果写进状态机,最后策略模块根据缺失槽位决定下一句反问什么。每一步都有自己的模型和规则,看起来分工明确,实际维护起来相当痛苦。
真正让我意识到这套架构天花板的是线上语料里的长尾表达。你可以用正则轻松处理"两张",但用户说"我跟朋友一块儿去",模型需要判断"一块儿"可能对应 2 人还是 3 人;用户说"晚点也行,别太晚",模型得理解"晚点"是相对当前时刻的模糊偏移;用户说"还是上次那家吧",光靠实体识别根本找不到影院。传统方案遇到这些句子,只能让用户回到固定菜单点选,或者不停地加规则。规则每多一条,后续维护成本就高一分,最后整个模块变成一团谁都碰不得的遗产。
1.3 大模型把"填格子"变成了"读懂一段话"
大模型介入之后,本质变化不是某个环节变强了,而是我们把意图分类、实体识别、对话状态跟踪三个步骤合并成了一个生成任务:给模型一段用户输入和槽位 Schema,让它直接输出结构化 JSON。更重要的是,它多出了上下文推理能力。
举个实际例子,用户说:"我妈七点半下班,帮我订她下班后那场。"传统实体识别只能抽到"七点半"和"下班后",但大模型可以结合当前时间和对话背景,推断出真正要订的场次大约在晚上八点前后。再比如"还是上次那家影院",模型能从历史对话里回填 cinema 字段。这种能力不是不能用传统 NLU 做,但你需要为每一个隐含推理场景单独设计规则,成本极高。
当然,合并成生成任务也带来了新的麻烦:输出不稳定、偶尔会编造用户没提过的值、难以像分类日志一样快速定位错误环节。这就是为什么我一直强调,大模型填槽绝不能只靠模型自由发挥,必须在外围建立工程约束。
2. 大模型做填槽的三条路线:提示抽取、函数调用、微调
2.1 纯提示抽取:最快上手的方案
如果只是想快速验证效果,最直接的方式就是把槽位定义全部写进 System Prompt,要求模型输出固定结构的 JSON。我常用的是下面这种模板:
你是一个任务型对话的语义解析模块。根据用户输入,只输出 JSON,不要任何解释。 意图列表:book_ticket, cancel_ticket, query_ticket, out_of_scope。 槽位定义: - movie_name: string,影片名,未提到填 null - show_date: string,日期,格式 YYYY-MM-DD,"明天"等相对时间须换算成具体日期 - show_time: string,时间,格式 HH:mm - cinema: string,影院名 - seat_count: integer,人数 - seat_area: string,座位区域偏好 输出要求: 1. 只输出 JSON,不要输出任何解释性文字。 2. 用户没有提到的槽位必须填 null,严禁猜测。 3. 如果用户表达存在歧义,在 ambiguity 字段中用一句话描述。调用代码如果后端是常见的大模型接口,通常长这样:
import requests import json API_URL = "https://api.example-llm-service.com/v1/chat/completions" def parse_user_input(user_text, history=""): payload = { "model": "your-model", "temperature": 0, "response_format": {"type": "json_object"}, "messages": [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": f"对话历史:{history}\n当前输入:{user_text}"} ] } resp = requests.post(API_URL, json=payload, timeout=10) return json.loads(resp.json()["choices"][0]["message"]["content"])这个方案优势是零训练成本,改 Prompt 就能调效果。它的问题也很明显:Prompt 越长,模型越容易漏字段;复杂嵌套关系难约束;日期格式偶尔会飘。所以我更愿意把它用在 PoC 或轻量场景里,直接面对大流量请求时要多留个心眼。
2.2 函数调用式填槽:结构化输出的正规军
如果模型服务支持函数调用能力,我更推荐把每个意图定义成一个可调用函数,让模型用"函数参数"的形式返回槽位。这样输出的不是自由文本,而是严格贴合参数签名的结构。
def book_movie_ticket( movie_name: str = None, show_date: str = None, show_time: str = None, cinema: str = None, seat_count: int = None, seat_area: str = None, ): """预订电影票。参数来自用户表达,没有提到的槽位不要填。""" return { "intent": "book_ticket", "movie_name": movie_name, "show_date": show_date, "show_time": show_time, "cinema": cinema, "seat_count": seat_count, "seat_area": seat_area, }把函数定义和描述传给模型之后,模型会返回一个结构化的调用请求,里面带着参数 JSON。相比纯 Prompt 抽取,函数调用有两个实际好处:一是模型对"参数必须符合函数签名"这层约束遵守得好很多,返回的 JSON 通常合法;二是它天然适合多轮流程,因为模型可以在参数字典里补上上一轮缺失的字段,而不是每次整段重写。
但函数调用并不能杜绝幻觉。模型仍有可能在某个参数没有依据时自己编一个值。下游还是要做候选校验,不能因为用了函数调用就放松警惕。
2.3 微调模型:垂直领域里效果更稳的另一个选择
当业务里存在大量专有名词,比如医疗科室名称、商品 SKU、内部订单类型,通用模型的先验知识根本不知道这些词长什么样,硬用提示词堆出一个几千项的候选枚举也不现实,这时候该考虑微调。
我的建议是先用提示词把流程跑通,攒下足够的标注样本,再对开源基座模型做 LoRA 这类轻量微调。数据按常见对话格式构造,assistant 侧直接放结构化槽位结果:
{ "messages": [ {"role": "user", "content": "帮我约一个下周二上午的皮肤科号"}, {"role": "assistant", "content": "{\"intent\": \"book_appointment\", \"department\": \"皮肤科\", \"date\": \"2026-05-26\", \"time_period\": \"上午\", \"patient_name\": null}"} ] }微调之后,模型对领域术语和简写的识别命中率会明显上升,输出稳定性往往也比通用模型好一截。但成本要算清楚:你需要准备上万条高质量样本,维护模型版本,做回归评测,还要处理基座升级带来的差异。对大多数中小团队,我不建议一上来就微调,先把前两条路榨干再说。
2.4 三条路线怎么选:一张对照表
| 维度 | 纯提示抽取 | 函数调用 | 微调 |
|---|---|---|---|
| 上手成本 | 低 | 中 | 高 |
| 结构化稳定性 | 中 | 高 | 高 |
| 领域术语支持 | 靠 Prompt 堆 | 靠 Prompt 堆 | 靠训练数据 |
| 迭代速度 | 快 | 快 | 慢 |
| 幻觉控制 | 弱 | 中 | 中 |
| 典型场景 | 原型、小流量 | 生产对话系统 | 专业垂直领域 |
我的个人经验是:能函数调用就别只靠提示词,能靠提示词解决就别急着微调。填槽模块最重要的是"可控",而可控性通常来自模型周围的工程约束,而不是模型本身有多聪明。
3. 多轮对话里的槽位管理:补全、覆盖与澄清
3.1 缺槽检测:先分清"用户没说"和"系统不知道"
生产系统里,缺槽并不总是用户的错。我们要分两种情况:一种是用户确实还没提供必填槽位,比如订票没留手机号,这时系统要主动澄清;另一种是用户说了,但模型无法从知识库里把它映射成标准值,比如用户说"我家附近那家影院",而系统不知道"我家"在哪。
我的做法是在 Prompt 里明确区分 "null" 和 "unknown"。"null" 表示用户没提,"unknown" 表示用户提了但我无法解析。模型返回 unknown 时,不急着追问同样的必填项,而是先解决候选问题,比如引导用户从影院列表里选择。这样的体验差异很明显,用户不会觉得机器在反复问同一句话。
澄清话术也要带上已收集的信息,比如:"好的,已确认 2 张、明晚 7 点场,请问在哪家影城取票?"而不是冷冰冰地来一句"请提供影院名称"。既然是对话系统,就要让每一轮追问看起来像自然延续。
3.2 跨轮槽位记忆:两种主流做法
多轮对话里最常踩的坑是:用户第一句说"帮我订两张明晚七点的票",第二句说"哦不用七点,改八点吧"。如果你只把第二句丢给模型,它根本不知道"改"的对象是什么;如果把整个历史全丢进去,也不是不行,但长对话里既费 token,又容易被无关内容干扰。
更稳的做法是维护一个外部槽位状态快照,每轮把当前状态以 JSON 形式注入 Prompt,要求模型输出完整的"新状态快照",而不只是本轮新增字段:
当前状态快照: {"movie_name": null, "show_date": "2026-05-20", "show_time": "19:00", "seat_count": 2} 当前用户输入:不用七点了,改八点吧 请基于快照更新槽位,输出完整 JSON。这样做的好处是,即使某轮模型理解得不好,编码层也可以对输出做字段级 diff,决定哪些槽位允许被覆盖、哪些要保持原样。它相当于把对话状态控制的最终决定权拿回工程手里。因为模型输出的是全量快照,哪怕它漏看了历史,至少还能保留上一轮的正确值。
3.3 槽位冲突与修正:用户改主意了怎么办
"改主意"是多轮对话里最高频的事件,而 LLM 并不擅长区分"新值替换旧值"和"对旧值的否定"。我建议不要让模型只输出字符串,而是让它输出带操作符的结构:
{ "intent": "book_ticket", "slots": { "show_time": {"op": "set", "value": "20:00"}, "show_date": {"op": "keep", "value": "2026-05-20"} } }当用户明确说"不要七点",模型应该把 show_time 置为 null,并把状态调整为"待确认",而不是执着地保留旧值。当用户说"算了,不订了",意图切换,所有槽位全部 clear。外层代码再根据这些操作符逐一应用,就能避免很多看起来莫名其妙的槽位跳变。
这部分工程化程度直接决定系统的可靠度。一个只会在单轮里抽槽位的 Demo,和一个能在真实对话里接住用户反复修改的生产系统,差距就在这里。
4. 落地最容易翻车的点:幻觉、歧义与脏数据
4.1 幻觉槽位的三道防线
大模型填槽最头疼的,是它会在没有依据的时候凭空填出一些看起来合理的槽位。比如用户只说"帮我订明天的电影票",模型可能擅自把 movie_name 填成"星海之战",因为没约束时它总想给你补全一点什么。对付幻觉,我习惯设三道防线。
第一道,Prompt 里反复强调"未提到的槽位必须填 null,禁止猜测",并给一个负例和一个正例。第二道,输出侧做严格校验,代码里写清楚每个槽位的合法范围:
import re from datetime import datetime def validate_slots(slots): errors = [] if slots.get("show_date"): try: datetime.strptime(slots["show_date"], "%Y-%m-%d") except ValueError: errors.append("show_date 格式错误") if slots.get("show_time"): try: datetime.strptime(slots["show_time"], "%H:%M") except ValueError: errors.append("show_time 格式错误") if slots.get("seat_count") is not None: if not (1 <= int(slots["seat_count"]) <= 10): errors.append("seat_count 超出范围") if slots.get("cinema") and slots["cinema"] not in VALID_CINEMAS: errors.append("cinema 不在候选列表") return errors第三道,把校验不过的请求送进二次确认流程,而不是直接报错。这三道防线叠下来,即使模型的输出偶尔不靠谱,用户体验依然可控。
4.2 同义表达与歧义处理
大模型对同义词的理解力远超传统实体识别,但理解同义词之后你还需要做归一化。用户说"看星战",而库里标准片名可能是"星海之战:黎明回响",模型输出标准名还是简称,很大程度上取决于 Prompt 里给的候选格式。我的建议是,遇到这种需要精确匹配的槽位,不要指望模型记忆,直接把候选列表放进 Prompt,并限定它从候选里选标准值。
歧义则是另一件事。用户说"去南站",可能指高铁南站,也可能指客运南站,模型随便给哪个都可能是错的。对这种歧义,最稳的策略不是猜,而是返回候选列表让用户确认。虽然多了一轮交互,却能避免一次错误下单带来的更大成本。落地中我宁愿系统多问一句,也不愿意它自作聪明。
4.3 置信度评分与人工接管
不是所有请求都该让模型一路走到底。生产系统里需要给模型输出打个分,低于阈值就走人工或回到规则流程。一个简单可用的置信度计算方式由三部分组成:必填槽位完整度、枚举槽位命中率、模型自评低置信度。
其中枚举命中率最客观,比如 cinema 字段不在候选影城里,这个槽位就得 0 分;必填缺失一个字段,完整性就会打折。三项加权后得到一个 0 到 1 之间的分数。低于 0.7,系统弹出确认话术;低于 0.4,直接转人工坐席。打分逻辑写在工程代码里,不依赖模型解释,上线后也好排查。
4.4 会话过期与槽位时效
还有一个容易被忽略的问题:槽位是有时效的。用户周一说了"明天",系统换算成 2026-05-26 存下来,如果这个会话被搁置到周三,用户回来说"我刚才说的那场,帮我订了吧",你再用 2026-05-26 去下单就错了。更常见的是场次座位和票价随时会变,槽位里的影院和场次必须在下单前重新校验有效性。
我会在状态快照里给每个槽位额外存一个 obtained_at 时间戳,超过一定时长后该槽位视为过期,需要引导用户重新确认。这个逻辑简单,却能在异步对话场景里救命。
5. 一个可复现的最小案例:电影票预订填槽
5.1 场景与槽位 Schema
把前面的思路落到一个可运行的例子上。假设我们做一个电影票预订助手,只处理订票、查票、退票三类意图。订票意图的槽位如上文表格所示。核心必填字段是日期、时间、影院、人数和联系方式,片名和座位偏好可以作为选填。
5.2 Prompt 模板
我用函数调用方式实现,函数签名如下:
def book_ticket( movie_name: str = None, show_date: str = None, show_time: str = None, cinema: str = None, seat_count: int = None, phone: str = None ): """预订电影票。当前对话状态可以作为上下文。""" # 返回槽位字典,由上层调度代码更新状态 return locals()5.3 多轮调度核心循环
下面是一个极简调度循环,重点展示状态维护和澄清逻辑:
def run_dialogue(): state = {"intent": None, "slots": {}} history = [] require_slots = ["show_date", "show_time", "cinema", "seat_count", "phone"] while True: user_text = input("用户:") history.append({"role": "user", "content": user_text}) result = call_llm_with_function(state, history) if result.get("intent") == "book_ticket": state["slots"].update(result["slots"]) missing = [s for s in require_slots if s not in state["slots"] or state["slots"].get(s) is None] if missing: reply = clarify_question(missing, state["slots"]) else: reply = f"确认订票:{state['slots']['cinema']},{state['slots']['show_date']} {state['slots']['show_time']},{state['slots']['seat_count']}张" print(f"助手:{reply}") break else: reply = "抱歉,我还不能处理这个操作" history.append({"role": "assistant", "content": reply}) print(f"助手:{reply}")这里 call_llm_with_function 会把上一轮状态和当前用户输入一起发给模型,模型返回参数后外层代码再更新状态。clarify_question 根据缺失字段生成话术,并把已填信息组合进去,让整个流程看起来更像真人。
5.4 离线评测与线上回流
跑通之后,第一件事是搭一套离线评测。最常用的指标是意图准确率和槽位 F1。意图准确率统计所有测试样本里意图判断正确的比例;槽位 F1 需要把预测槽位和人工标注槽位对齐,顺便校对值的格式是否完全一致。
| 指标 | 计算方式 | 我的经验阈值 |
|---|---|---|
| 意图准确率 | 正确意图数 / 总样本数 | 95% 以上 |
| 槽位精确率 | 正确槽位数 / 预测槽位数 | 90% 以上 |
| 槽位召回率 | 正确槽位数 / 标注槽位数 | 90% 以上 |
| 任务完成率 | 多轮内成功下单比例 | 85% 以上 |
线上系统需要采样回流通道,把每一轮的模型输出、用户反馈、是否人工介入都落到日志表。每周抽取一批失败样本,人工标注后加入评测集。填槽这类自然语言模块,最怕的不是初始效果差,而是没有人持续喂养和验证。
6. 实测中那些文档不会写的事
6.1 温度与随机性:同样的输入可能给出不同结果
即使把 temperature 设成 0,大模型的输出也不是严格确定的,尤其在长 Prompt 或者函数定义复杂的时候。实测里我发现,同样的用户输入在一天之内可能返回两种不同的日期格式,或者把同一个歧义表达判定成不同意图。为了应对这种不稳定性,关键场景我会对低置信度请求做两次调用取多数结果,但成本会翻倍。折中办法是只对低置信度的请求做二次采样,其他请求直接走单次输出。
6.2 候选集约束是个工程活
给模型塞候选列表要分层次。一个城市有几百家影城,全塞进 Prompt 会稀释模型对槽位本身的注意力,也会让 token 成本飙升。我的做法是让模型先填城市和影院名称,外层再用模糊匹配对齐到数据库里的标准 ID;只有候选数量在几十个以内时,才直接把列表放进 Prompt 让模型选。这个取舍处理不好,后面会出现模型输出"听都没听过"的影院名,校验逻辑只能干瞪眼。
6.3 从"填槽"到"真正理解意图"的下一步
最后说一点个人体会。LLM 填槽做得再好,也只是整个对话智能里"理解入口"的一环,真正决定用户体验的是理解之后怎么行动。你往往还要接库存查询、支付、订单状态同步,甚至跨系统调用。这时候不要让大模型直接操盘所有逻辑,而是让它输出去驱动你写好的确定性流程。模型负责把用户意图翻译成结构,业务规则负责把结构变成行动,各管一层,出了问题也更容易定位。
如果让我给准备开始做这件事的人一句建议,我会说:别把 LLM 当成"一个会说话的大脑",把它当成"一个偶尔走神但大部分时候很聪明的翻译官"。你在它周围做的每一道约束、校验、状态管理,才是系统真正靠得住的根基。