上篇聊了引导式对话系统的骨架,意图识别怎么做、对话树怎么画、用户不想被引导时怎么判断,这些都有讲过。这篇是下篇,主角是三个绕不开的硬骨头:槽位抽取、Function Calling、流程编排。很多团队做到这一步就卡住了,不是模型能力不够,而是这三件事的边界没划清楚,全搅在一个Prompt里。这篇文章的目的很直接:给一份能直接照着拆解和落地的方案,包括Schema怎么设计、工具描述怎么写、流程状态怎么管、翻车了怎么兜底。适合正在做智能客服、Agent、语音助手的开发者,也适合想搞明白大模型对话系统内部到底怎么运转的产品和技术同学。
1. 先想明白:槽位、工具和流程在系统里到底各干什么
1.1 三者关系的一句话总结
用一句话概括我的理解:槽位是系统的“记忆”,Function Calling 是系统的“手脚”,流程编排是系统的“大脑决策路径”。
记忆负责记住用户说了什么、缺什么;手脚负责去查库存、算价格、下单、调外部接口;决策路径负责决定每一步该先做什么、下一步走到哪、用户跑偏了怎么拉回来。
很多团队把这三件事混为一谈,典型表现是:把槽位定义直接塞进某个工具的 parameters,让流程编排完全靠模型自由发挥。结果就是模型偶尔聪明偶尔蠢,线上出问题根本不知道是记忆错了、工具选错了,还是流程走错了。所以动手写代码之前,先把边界划清楚。
1.2 多数团队卡住的第一步:把三件事揉进一个 Prompt
我见过一个比较典型的翻车案例:某同学想让对话系统支持“查天气顺便推荐穿衣”,他把天气查询工具的 schema 写得极其复杂,又把槽位规则、推荐规则全写进 system prompt,最后期望模型一步到位完成“抽取城市→查天气→生成推荐”。单看 demo 确实能跑通,但稍微换几个问法,比如“北京明天会下雨吗?那我穿什么好?”系统就开始乱。要么调了工具没传城市,要么传了城市又忘了穿衣推荐,要么干脆自己编了一个天气数据。
问题不在模型笨,而在职责不分。槽位抽取、工具调用、流程推进,必须拆成三个独立的模块,各自输入输出明确,才能稳定。这不是架构洁癖,是后续所有调试、回滚、灰度、扩能力的地基。你在这一步偷了懒,后面每加一个渠道、每加一个业务分支都会加倍还债。
1.3 我建议的分层架构
实际项目中我习惯分成三层:
- 表现层:负责把用户消息变成标准化的“意图 + 当前槽位状态”,把最终回复文本返回给用户。
- 决策层:也就是编排器,负责根据当前状态、下一步候选节点、槽位齐缺情况,决定是继续问槽位、调用工具、还是直接回复。
- 执行层:一组功能独立的工具(工具函数),加上一个统一的槽位状态存储器。
这三层之间用结构化数据传递,比如 JSON 状态对象,避免上下文满天飞。这样任何一个环节出了问题,都能单独测试。我下文讲的三部分,其实就是在讲这三层分别怎么落地。
2. 槽位抽取的三种落地姿势与多轮累积
2.1 大模型抽取的基本姿势:Schema 先行,模型只管填槽
最早的槽位抽取,主流方案是 BERT 加序列标注,在标注好的领域数据上微调,效果还行,但泛化差,改一个槽位就要重新标一批数据。换成大模型之后,思路变成:不再让模型学习“怎么抽取”,而是把抽取规则写清楚,让模型照着规则输出。本质上,我们把一个抽取问题变成了一个受约束的生成问题。
具体做法是:把槽位 Schema 放进系统消息,让模型读最近几轮对话,输出一个 JSON,里面是当前能确定的槽位值。举一个简化版例子:
{ "city": "北京", "check_in_date": "2025-03-01", "budget_range": null }这里有个关键细节:模型只看“这轮新增的信息”,不要让它自己去回忆前面几轮已经填过的槽位。前面几轮的状态由我们自己维护,放在状态存储器里,每轮抽取完做一次合并。这样做的好处是模型任务简单、幻觉少、调优成本也低。
2.2 槽位 Schema 设计:类型、枚举、别名、示例,一个都不能少
槽位 Schema 是大模型抽取的地基,字段少了模型只能靠猜。我常用的槽位 Schema 基本长这样:
{ "slot_name": "check_in_date", "description": "用户计划的入住日期,格式为YYYY-MM-DD", "type": "date", "required": true, "enum": null, "aliases": ["入住时间", "开始日期", "哪天开始住"], "examples": ["明天", "3月1号", "下周五"] }别小看aliases和examples这两个字段。实际对话里用户说“下周一来”,模型如果不认识“周一”是日期,就抽不出来。多给几个表达方式,抽取精度立刻上一个台阶。另外type也不能只写 string,日期、数字、枚举这些类型分开定义,模型才知道要不要做归一化。
2.3 多轮上下文里的槽位累积、覆盖与重置
单轮抽取好做,多轮才是难点。用户第一轮说“我想租个两居室”,第二轮说“预算八千以内”,第三轮说“算了,先看看一居室”。这时候两居室要不要清掉?预算八千保留还是也清掉?
我的处理规则是:
- 新增:本轮出现的新槽位直接并入状态。
- 覆盖:同一个槽位,本轮有明确新值,则覆盖旧值。
- 重置:用户表达了“不要/改了/换一个”这类否定或变更意图,关联槽位全部置空。
- 不处理:本轮没提到某个槽位,旧值保留。
这个逻辑看似简单,但必须有独立的模块去实现,不能指望模型自己判断“两居室是不是要保留”。因为在自然语言里,“算了换成一居室”和“再加一个一居室的选项”是完全不同的意图,模型在没有明确指令时很容易自作主张。我在项目中会额外维护一个slot_intents标记,记录每次槽位变化是新增、覆盖还是重置,方便后面流程编排做决策。
2.4 槽位确认策略:显式确认、隐式确认和“算了先跑”
槽位抽出来之后,要不要跟用户确认?这个决策直接影响体感。核心判断依据是:这个槽位错了,代价有多大。
- 低风险槽位(比如城市、房型):用隐式确认。例如“好的,帮您找北京两居室,预算八千以内,对吗?”一句话带过,用户不反对就算确认。
- 高风险槽位(比如扣款金额、手术时间、地址):用显式确认。必须让用户明确回复“确认/可以”才继续。
- 不确定槽位(模型的置信度低,或者同一轮抽出了多个候选):主动反问,“您说的下周,是指周一还是周日?”
这里我踩过的坑是:不加区分地对所有槽位做显式确认,导致对话极其啰嗦。用户说十句话,系统回八句“确认吗”,体感像在审户口。后来改成按风险分级确认,对话流畅度明显提升。
2.5 槽位抽取最常见的翻车现场
| 场景 | 翻车表现 | 补救方式 |
|---|---|---|
| 用户说“随便”“都行” | 模型把“随便”当成了某个槽位的值 | 把“随便”这类泛化词定义成特定值,触发反问或进入推荐逻辑 |
| 用户给的信息和 schema 对不上 | 模型硬填一个不存在的枚举值 | 枚举之外的输出一律置空,让流程反问 |
| 同一轮出现多个相同槽位候选 | 模型随机选一个,可能选错 | 要求模型输出候选数组,由规则层去重或让用户选择 |
| 用户临时回应答非所问的话 | 把闲聊内容抽成槽位 | 增加一轮“是否与当前任务相关”的判断 |
表格里每一行都是真实线上出过的问题,尤其是“随便”这个词,我一开始没处理,结果系统中出现了一个叫“随便”的城市,查了半天才发现用户真是个天才。
3. Function Calling 的工程化落地
3.1 Function Calling 不是一个开关,而是一个循环
很多人以为 Function Calling 是模型自带的一个能力,开启后模型就能自动调外部 API,其实没那么简单。它的本质是:模型学会输出一个结构化调用指令,然后由你的代码去真正执行。这个循环包含四步:
- 定义工具清单(名称、描述、参数),传给模型。
- 模型根据用户需求和上下文,输出
tool_calls,里面指定要调用哪个工具、填什么参数。 - 你的代码校验参数,执行真正的函数调用。
- 把执行结果以“工具消息”的形式反馈给模型,模型再生成面向用户的最终回复。
用租房场景举例,模型输出的 tool_calls 大概是:
{ "tool_calls": [ { "id": "call_001", "type": "function", "function": { "name": "search_houses", "arguments": "{\"city\": \"北京\", \"budget\": 8000, \"room_type\": \"一居室\"}" } } ] }注意,到这里模型还没真的查数据库,只是“请求”你去查。真正的房源码,永远在你的服务端。
3.2 工具描述怎么写,模型才不瞎选、瞎填参数
工具描述是模型决策时唯一的依据,写得不好,模型要么不调工具,要么乱调。我总结的三个原则:
第一,触发条件要写清楚。不要只写“查询房源”,要写“当用户表达找房、租房、看房等意图,且城市、预算、房型任一条件已明确时调用。缺参数时不要调用,转为询问用户。”
第二,参数含义要写透。每个参数都要写它的业务含义、格式、边界。比如budget参数,描述里要写“用户能接受的月租金上限,单位元,整数。注意对话中出现的‘八千’要转为8000”。
第三,返回内容要写预告。模型需要知道调用完这个函数能得到什么,才能决定下一步怎么引导用户。比如返回里包含房源列表、每套房源的价格和位置,模型就能据此做推荐。
我还发现一个常见问题:一个工具的 description 写太长,超过上下文窗口了。工具描述不是越多越好,而是该简洁的简洁、该明确的明确。如果工具描述超过几百字,建议拆分成两个工具。
3.3 参数校验:模型输出的 JSON 必须过一道硬校验
模型的输出本质是文本,它不保证每次都生成合法 JSON、不保证参数枚举不出错。所以 Function Calling 的代码里,必须有一道硬校验,在真正执行函数之前挡住脏数据。
我在项目中用的校验规则很简单:
- 用 JSON Schema 校验参数是否符合定义(类型、必填、枚举、格式)。
- 校验数值边界,比如预算不能为负数,日期不能早于今天。
- 校验业务白名单,比如城市必须在支持列表里。
- 校验必填参数是否齐全,不齐就触发槽位反问流程,而不是带着缺参去调服务。
这道校验卡住了大量线上问题。有一次用户说“帮我找便宜点的房子”,模型把 budget 填成了 "便宜",如果没有校验就把字符串传给了下游搜索服务,直接 500。有了校验,系统才能正常走“反问预算区间”的流程。
3.4 工具执行结果如何回灌给模型
工具执行完,返回结果是一段结构化数据,你需要把这段数据包装成一条“工具消息”再发给模型。这里的关键是:不要让模型自己去猜结果的含义,尽量把结果整理成和用户问题有关的形式再回传。
比如说,房源搜索返回:
{ "total": 12, "houses": [ {"title": "望京两居室", "price": 7500, "area": 68}, {"title": "回龙观一居室", "price": 5200, "area": 45} ] }你可以原样回传,模型也能读,但如果信息太多,模型可能会挑错重点,或者把数字念错。更稳妥的做法是,在回传前先做一个后处理,把最关键的字段抽出来、格式化好,再交给模型生成回复。甚至可以把排序逻辑放在服务端,模型只负责把已经排好的结果讲给用户听。
3.5 必须处理的异常:工具超时、参数缺失、结果怪异
Function Calling 落地之后,异常处理是绕不开的一环。我在测试环境里跑得很顺,一上生产各种问题就来了:
- 工具超时:外部 API 3 秒没响应,用户等得暴躁。给每个工具设置独立超时时间,超时后回退话术,比如“房源信息暂时查不到,我们换个时间再试?”
- 工具返回空:查了数据库没有匹配结果。不能就这么直接说“没有”,应该触发流程重新引导用户放宽条件。
- 结果异常:返回的数据格式和预期不符。要么重试一次,要么走兜底回复,不要把错误堆栈暴露给用户。
- 参数里带敏感字段:用户给了手机号,模型可能把手机号当参数传给工具。要按业务需求做字段过滤和脱敏。
这部分的工程量和模型调用本身差不多大,容易被低估。但恰恰是这些“异常分支”决定了系统能不能真正上线。
3.6 关于调用的边界与二次确认
工具调用不仅要考虑能不能调,还要考虑该不该调。我实践中会做两层防护:白名单——只有注册过的工具才会出现在模型的工具清单里;敏感操作确认——涉及扣费、下单、隐私信息变更这类动作,必须让用户再确认一次,确认行为放在流程编排层实现,而不是让模型自由裁量。
这不算技术复杂,但是上线前必须考虑。一句话:Function Calling 可以让模型“会做事”,但“能做什么事、能不能直接做”是产品规则,应该由我们控制,而不是由模型临场发挥。
4. 流程编排:把对话树变成可运行、可回退的状态机
4.1 节点对象模型:对话流程归根到底是一棵有限状态树
引导式对话的流程,本质上是一棵有限状态树。每个节点代表一个“对话状态”,比如“收集城市”“询问预算”“展示房源”“预约看房”。节点之间通过条件边连接。
我习惯用一个 JSON 来描述流程定义:
{ "node_id": "ask_budget", "type": "slot_collect", "target_slot": "budget_range", "prompt": "您的预算大概在什么范围?", "next": [ {"condition": "budget_range != null", "target": "search_houses"}, {"condition": "budget_range == null", "target": "ask_budget_again"} ] }实际项目里我不会写复杂的流程编排引擎,而是直接基于这个对象模型,用代码去实现状态迁移。核心是:状态是显式的,迁移是确定的,每一步都有据可查。
4.2 引导式流程的核心:预期槽位与拉回策略
引导式对话和自由对话最大的区别在于:系统有一个“当前预期目标”。比如这轮我想从用户那里拿到预算,那么用户说什么话都要能导向预算,这就是引导。
但用户不会乖乖配合。你说“您的预算大概多少”,他回“便宜的就行”。这时候怎么办?如果直接把“便宜的”存入 budget,后面搜索逻辑又没法比较。我的做法是:定义一个“满足度”函数,判断当前节点是否可以说结论。
比如 search_houses 节点要求 city、budget_range、room_type 三个槽位齐全。如果用户只给了一个“便宜的”,那就进入二级引导:“了解,那咱们按预算五千以内先找,可以吗?”这里用了隐式假设 + 显式确认,把模糊信息转化为明确槽位值。
拉回策略在流程编排里的意义,不是把用户拽回原来的话,而是:接受用户的新信息,更新状态,再看当前节点能不能推进。能推进就推进,不能推进就换一个更合适的节点。
4.3 分支、回退、打断:状态栈比你想的更常用
光有状态树还不够,真实对话里用户会打断、会反悔、会临时问别的问题。所以我在流程引擎里维护了一个状态栈,而不仅仅是一个当前节点。
- 用户正在问“预算”,突然说“等等,你们公司在哪?”——这时压栈一个“临时问答”状态,答完后自动弹栈回到“预算”问题。
- 用户说“算了,我不租房了”——这是终止意图,直接清空状态栈,进入结语。
- 用户说“我之前问的那个两居室还在吗”——这需要能回退到历史节点,状态栈里要保留历史记录。
这个状态栈看似简单,但能解决很多大模型对话系统里的“记忆错乱”问题。因为栈是显式的,模型不需要自己记住现在到底该聊到哪里,它只需要读栈顶节点,按节点要求执行。
4.4 编排器与 LLM 的分工:小步执行,别让模型从头编到尾
刚开始做引导式对话时,很容易想走捷径:让大模型一个人从头规划到结束,把所有步骤一次性生成。但这样有两个致命问题:一是无法观测中间状态,出了错不知道哪里错的;二是不可回滚,模型编错一步,后面全跟着错。
后来我把编排器抽出来,改成每轮只让 LLM 做一个小决策。比如:
- 当前节点是 ask_budget,我就只让模型从用户消息里抽 budget 这个槽位,不回传所有槽位。
- 当前节点是 search_houses,我只让模型判断“参数是否齐全、要不要调用工具”,不生成最终回复。
- 工具结果回来后,我再让模型基于结果生成一段自然语言。
这样做的结果是,每一步的输入输出都很窄、很清晰,出了问题能精准定位到具体节点。成本也没增加多少,因为每轮本来就要调模型,只是把一个大 Prompt 拆成了几个小 Prompt。
4.5 上下文窗口管理:槽位状态注入与历史压缩
对话轮次多了,上下文会超长。尤其是槽位状态这种信息,更新频率高但内容冗余,全部塞给模型很浪费。我的处理策略:
- 槽位状态单独维护,每轮把最新的槽位表注入 system 消息,而不是把每轮抽取结果堆在 history 里。
- 历史消息做裁剪,只保留最近 N 轮完整对话,更早的对话压缩成一段摘要文本,连同槽位状态一起注入。
- 工具返回结果不长期保留,执行完就摘掉,只保留最终面向用户的那条文本。
这套组合拳下来,即使聊了二三十轮,上下文也能保持在可控长度,模型输入稳定,推理速度也快不少。很多团队不做这件事,等到线上对长对话就卡死,回头才发现太多无关历史占据着窗口。
5. 一次完整落地实录:租房引导流程拆解
5.1 场景与流程定义
下面我用一个完全虚构的租房对话场景,把槽位抽取、Function Calling、流程编排串起来走一遍。流程定义是这样的:
- 意图:用户想租房。
- 收集槽位:城市、预算范围、房型。
- 工具:
search_houses搜索房源、book_viewing预约看房。 - 节点链路:start → collect_city → collect_budget → collect_room_type → search_houses → book_viewing → end。
5.2 第一轮:用户说“我想租个房子”
用户:我想租个房子。
系统进入 start 节点,意图识别出来是“租房”,开始走流程。当前缺少城市槽位,于是编排器选择 collect_city 节点。模型生成话术:
“好的,您想在哪个城市租房?”
此时槽位状态表还是空的:
| 槽位 | 值 | 状态 |
|---|---|---|
| city | null | 待收集 |
| budget_range | null | 待收集 |
| room_type | null | 待收集 |
这一轮没有触发任何工具,只是收集信息。看起来简单,但很多系统在这就会犯一个错:模型没走节点,而是自由发挥说了一堆话,把用户带偏。有了编排器,模型生成话术的前提就是“当前节点是 collect_city”,不会跑偏。
5.3 第二轮:用户给预算,模型触发搜索
用户:北京,预算八千以内,想找一居室。
这一轮模型拿到三个槽位,全部抽取成功。槽位状态表更新:
| 槽位 | 值 | 状态 |
|---|---|---|
| city | 北京 | 已有 |
| budget_range | 8000 | 已有 |
| room_type | 一居室 | 已有 |
编排器判断 search_houses 节点的前置条件已经满足,进入该节点。模型输出工具调用指令:调用search_houses,参数就是北京、8000、一居室。代码层校验通过后,执行搜索服务,返回一批房源。工具结果回灌,模型生成回复:
“北京一居室有个不错的选择,望京一套月租7500,回龙观一套5200,您想看看哪里的详细情况?”
到这里,流程已经走完一轮工具闭环。注意,这轮模型就只干了两件事:抽取槽位、生成工具调用,中间没有任何多余的自由发挥。
5.4 第三轮:用户临时问地铁站,怎么拉回
用户:望京那套离地铁站近吗?
这是个典型的“打断”。用户没说要继续看房,而是问了一个新问题。流程编排器发现这不是当前节点的预期输入,也不影响任何槽位。于是压栈一个临时问答节点,让模型基于工具返回的房源详情回答距离问题。
用户:那周六可以看房吗?
回答完地铁问题后,自动弹栈回到 search_houses 节点。模型发现当前已经有了房源上下文,用户新表达了一个“预约周六看房”的意图,于是走 book_viewing 节点,再次调用book_viewing工具。
这个流程如果没有状态栈,极容易断——模型会忘记之前聊到哪了,或者把“周六看房”理解成新开一个话题。有了状态栈,打断、回退、再推进都非常自然。
5.5 第四轮:参数校验失败,系统兜底
用户:就约周六下午两点吧。
模型调用book_viewing,参数里却把时间写成了"周六下午两点"这样的文本,而不是标准化的"2025-03-01 14:00"。参数校验层发现时间格式不合法,拒绝执行,触发兜底分支——不直接报错,而是反问确认:
“好的,那咱们约周六下午两点,也就是3月1号下午2点,对吗?”
用户确认后,系统重新生成标准时间参数,再次调用工具,预约成功。这个场景在真实项目里非常常见,模型输出格式不稳定的问题,不能靠祈祷模型变聪明解决,只能靠参数校验加兜底流程去兜住。
5.6 完整状态变化
我用一张表总结上面几轮的状态迁移:
| 轮次 | 用户输入 | 槽位状态变化 | 工具调用 | 输出 |
|---|---|---|---|---|
| 1 | 我想租个房子 | 无变化 | 无 | 询问城市 |
| 2 | 北京,预算八千以内,一居室 | 三个槽位全部填充 | search_houses | 展示房源列表 |
| 3 | 望京那套离地铁站近吗 | 无变化 | 无 | 回答距离问题 |
| 3.5 | 那周六可以看房吗 | 无变化 | book_viewing | 询问具体时间 |
| 4 | 就约周六下午两点吧 | 无变化 | 参数校验失败后重试成功 | 确认预约成功 |
这张表放在生产系统里,就是一套完整的对话状态日志。我们排查问题时,第一步就是看这个表,基本十秒内能定位是哪一轮、哪个环节出了错。这也是我做这套系统最看重的东西:让你能看清楚对话的每一步。
6. 我在项目里反复踩的坑和调优建议
6.1 Prompt 越写越复杂,问题越多
刚上手时,我总想在一个 Prompt 里塞进所有规则,结果 sys prompt 写到两千字,参数越调越玄学。后来我强制自己遵循一个原则:每个模型调用,只负责一件事。抽取就只抽取,选工具就只选工具,生成回复就只生成回复。复杂业务逻辑全部落到代码层。这个原则帮我解决了很多线上问题。
另一个提示是,当模型输出不稳定时,先不要怀疑模型,先检查是不是你的输入太脏了:槽位状态表是不是冗余字段太多?工具描述是不是有歧义?历史消息是不是混入了无关内容?我至少有七成问题是在这些地方找到根因的。
6.2 成本黑洞:每轮对话调用模型次数失控
流程编排小步执行虽然可观测,但也要注意控制模型调用次数。我的一个过程化方案是:能合并的合并,能不调的不调。
比如用户第一轮就把城市和预算都说全了,那就没必要先走 collect_city 再走 collect_budget,编排器直接跳到 search_houses。另外,临时问答这类非关键节点,能用检索或规则命中的,尽量不要调大模型。每少一次调用,不仅省成本,还省延迟。
我在实际项目里给每个用户会话设了一个模型调用上限,比如 15 次,超了就转人工。这可能不科学,但能防止异常对话把成本烧爆。等系统稳定之后再慢慢放宽。
6.3 可观测性:没有 Trace,你根本没法调参
没有完整的对话状态日志,调对话系统就像闭着眼睛开车。我在上线之初就要求:每一轮模型调用都记录输入输出,每一个节点迁移都记录原因,每一次工具调用都记录参数和结果。
具体落到日志里,就是前面那张状态变化表,再加上每轮的 model prompt、model response、耗时、token 数。这样当用户反馈“系统答非所问”时,我可以回放整个会话,看出是槽位抽取错了、工具参数错了,还是流程走到了错误节点。这套日志也是我后来做自动评测的种子数据。
6.4 灰度与回滚:流程节点要能独立开关
代码可以发布回滚,但对话流程本身也要能快速调整。我的做法是:每个流程节点都有开关,每个工具的入口版本化。新流程先在测试环境跑通,再在小流量上灰度,观察用户主动打断率、转人工率、任务完成率这些指标,之后再逐步放开。一旦数据异常,立刻把节点切回旧版本。
有一次我们把“收集预算”节点的引导话术从开放问题改成了选择题,结果用户完成率反而下降。数据一出来,马上回滚。如果没有灰度机制,这种体验问题要到全量上线才会暴露,代价就大了。
6.5 一个小技巧:把槽位抽取结果直接打印成状态表
最后分享一个实战里很受用的小技巧:在开发和调试阶段,把每轮槽位抽取结果、流程状态、工具调用直接打印成一张表,放到日志或调试面板里。别看这是件小事,它帮我快速建立起“系统是如何理解用户”的直觉。
当你盯着几十个真实用户的对话状态表看一段时间,就会发现很多规律:哪些表达方式最容易抽错、哪个节点最容易把用户带跑、哪类问题最适合加引导话术。这种直觉,是看任何文档都得不到的。
我最初做这套系统的时候,最深的感受就是:槽位抽取、Function Calling、流程编排这三件事,单拎出来都不算难,但把它们组合成一个稳定的对话系统,靠的是把职责边界划清楚、把每一步的状态显性化、把每个异常兜底都想到。希望这篇把流程讲透了之后,你在自己的引导式对话项目里能少走几圈弯路。