大模型对话系统落地:槽位抽取、Function Calling与流程编排实战解析

发布时间:2026/10/22 3:30:53
大模型对话系统落地:槽位抽取、Function Calling与流程编排实战解析
上篇聊了引导式对话系统的骨架意图识别怎么做、对话树怎么画、用户不想被引导时怎么判断这些都有讲过。这篇是下篇主角是三个绕不开的硬骨头槽位抽取、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 节点。模型生成话术“好的您想在哪个城市租房”此时槽位状态表还是空的槽位值状态citynull待收集budget_rangenull待收集room_typenull待收集这一轮没有触发任何工具只是收集信息。看起来简单但很多系统在这就会犯一个错模型没走节点而是自由发挥说了一堆话把用户带偏。有了编排器模型生成话术的前提就是“当前节点是 collect_city”不会跑偏。5.3 第二轮用户给预算模型触发搜索用户北京预算八千以内想找一居室。这一轮模型拿到三个槽位全部抽取成功。槽位状态表更新槽位值状态city北京已有budget_range8000已有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、流程编排这三件事单拎出来都不算难但把它们组合成一个稳定的对话系统靠的是把职责边界划清楚、把每一步的状态显性化、把每个异常兜底都想到。希望这篇把流程讲透了之后你在自己的引导式对话项目里能少走几圈弯路。