
最近在开发者圈子里频繁看到cua这个缩写很多人一头雾水以为又是什么新的前端框架或者编程语言。查了一圈资料发现它是Conversational User Agent的缩写简单说就是对话式用户代理。这个方向其实已经火了一段时间只是最近因为几款大模型产品的迭代把它重新推到了台前。我花了两周时间把 cua 从概念到落地完整做了一遍实测这篇就围绕cua 到底是什么、底层逻辑怎么设计、如何从零搭建一个可用的 cua、实际跑起来有哪些坑这四个维度来展开。这篇文章适合正在做 Agent 开发、智能客服系统或者对 AI 产品落地感兴趣的朋友不需要你有很深的技术功底我会把关键细节和代码逻辑都拆开讲清楚。1. 内容整体设计与思路拆解1.1 cua 到底是什么和 chatbot 有什么区别很多人第一反应是cua 不就是个聊天机器人吗这个理解其实差了很远。传统 chatbot 是你问我答的单轮逻辑用户说一句话系统去知识库匹配一个答案回回来整个过程是无状态的。cua 的核心区别在于它带了用户意图理解和任务执行的能力。你可以把 cua 理解成一个有手有脚能干活的那个客服而不只是一个会说话的喇叭。我用一个生活化的类比来解释传统 chatbot 就像餐厅门口的迎宾员你问他今天有什么推荐他能根据菜单给你报一遍。但你要是说帮我订一个靠窗的位置四个人晚上七点到他只能告诉你这个我不太清楚您进去咨询一下。cua 则是那个真正能帮你完成预定、确认时间、通知后厨的全流程服务员。在实际落地中这意味着 cua 需要具备三个核心模块意图识别引擎、对话状态管理DST和任务执行器Tool Executor。这三个模块缺一不可少了任何一个它都会退化成传统 chatbot。我见过很多团队在起步阶段就踩了这个坑——直接调用一个大模型接口然后告诉老板我们做了一个 cua。结果实际使用时发现模型能理解用户说什么但完全不知道该去查哪张表、调哪个接口、怎么把结果组织成对话回给用户。这就是没有做任务执行层设计导致的。1.2 为什么选择 cua 这种架构它解决了什么问题聊完概念我们来看看设计思路上的选型问题。我这次做的是一个智能售后客服 cua需求方要求它能处理退换货、物流查询、发票申请这三类核心业务同时支持多轮对话中切换话题。选择 cua 架构而不是传统的历史对话匹配方案核心考量有三个。第一个是复杂意图的表达能力。三个业务听起来不多但真实用户不会按你的分类来提问。比如用户说我上周买的那个充电宝有点问题想退了然后顺便问下我的耳机是不是也发货了这句话里同时包含退货意图和物流查询意图传统检索式方案几乎无法处理这种混合意图。而 cua 可以让大模型先做意图拆分再逐个执行最后拼接回复。第二个是多轮对话的状态保持。用户在第一轮说我要退货第二轮不会把订单号、商品名称、退货原因全部重复一遍而是会说就是这个订单或者刚才说的那个。cua 的对话状态管理模块能把前几轮的信息持续保留并更新槽位传统方案面对这种指代消解非常吃力。第三个是可观测性和可回退性。真实业务系统最怕黑盒cua 的每个决策路径意图、槽位、执行动作都可以记录成结构化日志一旦出了问题开发团队可以直接从日志里定位是哪一轮理解错了还是工具执行报错了。这在客服场景拿到合规审查面前是刚需也是技术选型时最有说服力的理由。2. 核心细节解析与实操要点2.1 意图识别不是简单一层 prompt 就完事cua 的意图识别是整个架构的入口这里有一个很关键的原则不能用一层 prompt 包打天下。我实测过把一个复杂的系统提示词直接丢给大模型让它一个人同时判断用户想干什么、需要什么参数、要不要调用工具、调用哪个工具效果在 Demo 阶段看着很惊艳一到并发真实流量就崩了——漏意图、错调用、回话逻辑混乱问题一个接一个。正确做法是拆成两层第一层做轻量意图粗分类第二层做细节槽位填充和校验。第一层用大模型或者小模型都可以只做一件事把用户输入归入预定义的几个大类退换货、物流、发票、闲聊。这里我用的是一个微调过的二分类模型分支准确率大概在 94% 左右速度控制在 200ms 以内成本很低。第二层才是大模型发挥的地方它的输入除了用户原话还会带上第一层的意图标签和历史对话状态。例如用户输入我那个订单要退第一层给出退货意图第二层大模型负责从原话里抽出order_id、reason这两个槽位。如果槽位缺失它会主动反问用户补齐信息。这样拆的好处有三个模型各司其职故障点容易定位而且当你需要新增一个意图类别时不必重新调试一整条链路。关于意图识别我再分享一个踩过的坑用户说的词往往跟业务后台的词不一致。比如业务后台叫退货退款用户说我要退钱我不想买了直接帮我取消这个怎么退啊这些表达看起来都是退货但语义上其实是有细微差别的。我最终的做法是在第一层意图标签里加了一个pre_intent疑似退货然后等第二层大模型做二次确认同时把业务后台的术语词表同步给大模型做参考。2.2 对话状态管理用槽位填充代替聊天记录硬塞对话状态管理是 cua 和多轮聊天机器人的最大分水岭。如果没有 DST 模块你只能把整段历史记录全部塞给大模型让它自己去悟哪些信息是已经确认过的、哪些还没拿到。这样做在小规模测试上没问题但在长对话、话题频繁切换的场景下token 消耗和错误率都会成倍上升。我这里采用的是经典的槽位填充 状态标志位方案。以下单退货场景为例我定义了三个槽位字段槽位字段类型是否必填说明order_idstring是订单编号reasonstring是退货原因confirm_statusenum是是否确认退货confirm / pending对话状态表里会有current_slot当前正在哪个槽位、slot_status整个槽位的填充进度全部填完、缺哪些这些标志位。当用户一开始说我要退货DST 会标记confirm_status pending并生成一个反问节点请提供订单编号和退货原因。用户后来输入的信息会先经过 entity extraction 模块识别出哪些文本对应哪个槽位再更新到状态表里。这里有一个很多人容易忽略的点槽位不是只填充一次就完事是允许被覆盖的。用户可能先用旧订单号发起退货聊着聊着又说算了我退的是另外一个订单DST 需要能合法地更新order_id的值而不是识别到冲突后直接报错。整个 DST 模块最好不要让大模型直接读写状态表而是封装成一个独立的小服务通过接口暴露get_state() / update_state()。否则大模型的 token 输出不稳定可能导致状态被幻觉篡改。2.3 工具执行层让 cua 真正有手工具执行层是 cua 从聊天变成做事的关键。我给 cua 挂了两个真实的工具接口一个是订单查询接口查订单状态一个是退货申请接口提交退货单。工具执行的核心设计问题是怎么让大模型知道什么时候该调工具、调哪个工具、参数怎么填。我的做法是给每个工具写一段 OpenAPI 规范的描述然后让大模型根据对话内容输出一个结构化的函数调用指令。以查询订单为例工具描述大致是这种思路{ name: query_order, description: 根据订单号查询订单最新状态适合用户询问物流、签收情况、发货状态时调用, parameters: { order_id: { type: string, description: 用户提供的订单号必须是纯数字或字母组合 } } }当大模型判断需要调用工具时它会输出一个 JSON 结构包含tool_name和tool_args然后由代码层负责真正发起 HTTP 请求并把结果喂回给大模型。实际跑链路时我给每个工具都设置了重试机制和超时保护。订单查询接口偶尔会超时或返回 5xx如果工具层没有重试cua 会直接把异常结果当成系统状态未知回复给用户这体验很糟糕。这里也补充一个经验工具返回的结果千万不要原样丢给大模型让它自由发挥。比如订单接口返回的 JSON 里有status_code 4002这样的内部错误码大模型不了解这个码的含义就会乱编一个回复。我最终是在工具层先做一层结果归一化把接口返回的数据翻译成自然语言摘要比如订单号 30291934 当前状态是已签收签收时间为 6 月 18 日 14:20再把这段摘要喂给大模型组织最终回答。3. 实操过程与核心环节实现3.1 整体架构与数据流设计进入实际操作部分我先给大家讲一下我搭建的整体架构。整个 cua 服务分为四个模块接入层API Gateway、状态管理层DST Service、决策层LLM Router、工具执行层Tool Executor。整个数据流是这样的用户消息 - API Gateway 做基础预处理去噪、敏感词过滤 - 送入意图粗分类 - 携带粗分类结果和历史状态送入大模型 - 大模型输出意图 槽位 工具调用指令 - DST Service 更新槽位状态 - 如果需要调用工具则调用 Tool Executor - 结果归一化后回传大模型 - 生成最终对话回复 - 返回给用户。这里每一步都不能省尤其 API Gateway 的基础过滤。我一开始偷懒没做结果有一次用户在对话里输入了一串 HTML 标签后端直接报错整个服务超时之后我才老老实实加了这层防护。3.2 核心代码实现状态管理 工具调度这一节我们来实际看一下我实现的核心代码。语言选择上我用了 Python 3.10 FastAPI 作为主服务框架原因是生态成熟、社区资源多大模型相关的 SDK 都是原生支持 Python 的。首先是 DST Service 的实现我维护了一个内存中的会话状态字典生产环境下建议替换为 Redis支持分布式扩展# dsl_state.py import json import time from typing import Dict, Optional class DialogState: def __init__(self, session_id: str): self.session_id session_id self.slots: Dict[str, Optional[str]] { order_id: None, reason: None, confirm_status: pending, } self.history: list [] self.created_at time.time() self.updated_at time.time() def update_slot(self, key: str, value: str): if key in self.slots: self.slots[key] value self.updated_at time.time() def is_complete(self) - bool: return all(v is not None for v in self.slots.values()) def to_prompt(self) - str: filled {k: v for k, v in self.slots.items() if v is not None} return f当前已确认信息: {json.dumps(filled, ensure_asciiFalse)} class StateManager: def __init__(self): self.states: Dict[str, DialogState] {} def get_or_create(self, session_id: str) - DialogState: if session_id not in self.states: self.states[session_id] DialogState(session_id) return self.states[session_id] def clear(self, session_id: str): self.states.pop(session_id, None)这段代码的思路很直观slots里存的是当前会话已经确认的信息to_prompt()负责在每次请求大模型前把已填充槽位转成文本拼进 prompt避免一上来就丢一整段聊天记录。接着是工具调度的实现核心是一个简单的 router根据大模型输出的tool_name分发到具体的执行函数# tool_router.py import requests from typing import Dict, Any TOOL_TIMEOUT 5 MAX_RETRY 2 def query_order(order_id: str) - Dict[str, Any]: url fhttps://api.example.com/order/{order_id} for attempt in range(MAX_RETRY): try: resp requests.get(url, timeoutTOOL_TIMEOUT) if resp.status_code 200: data resp.json() return { status: ok, summary: f订单 {order_id} 当前状态为 {data[status_text]} } else: raise Exception(fHTTP {resp.status_code}) except Exception as e: if attempt MAX_RETRY - 1: return { status: error, summary: f订单查询失败请稍后重试{str(e)} } # 工具注册表 TOOL_REGISTRY { query_order: {func: query_order, description: 查询订单状态}, apply_return: {func: apply_return, description: 提交退货申请}, } def execute_tool(tool_name: str, args: Dict[str, Any]) - Dict[str, Any]: tool TOOL_REGISTRY.get(tool_name) if not tool: return {status: error, summary: f未知工具: {tool_name}} try: return tool[func](**args) except TypeError as e: return {status: error, summary: f工具参数错误: {str(e)}}apply_return函数这里没有展开但它实际去做的是组装退货参数、调用业务系统 API、并返回申请单号。关于这部分有个小提示工具函数的参数名称最好和大模型输出对齐否则会经常触发TypeError我在调试时遇到很多次boss 缺少关键词参数的情况后来统一了参数命名规范才解决。3.3 大模型 Prompt 模板设计Prompt 模板可以说是 cua 效果的灵魂我这里直接放出核心模板并解释设计理由。你是一个智能售后客服助手负责处理用户的订单查询、退货申请、物流咨询等任务。 【当前已确认信息】 {state_prompt} 【用户输入】 {user_input} 【可用工具】 {tools_desc} 【最近对话摘要】 {summary} 【你的输出要求】 1. 如果用户输入中可以提取到订单号或退货原因补充到已确认信息中。 2. 如果需要调用工具完成任务输出格式必须为: [TOOL_CALL] {tool_name: ..., args: {...}} [/TOOL_CALL] 3. 如果槽位信息不完整向用户追问缺少的信息。 4. 如果用户输入与业务无关直接以自然语言回复用户。 5. 如果需要调用工具且工具执行完毕基于工具返回结果回应用户不要编造工具返回值。模板里有几个设计细节值得说state_prompt只列已确认信息不列对话历史这能大幅减少 token 开销也让模型更聚焦当前要完成的任务。tools_desc是动态拼接的它是根据当前意图相关度过滤后的工具描述比如用户意图是退货就不把物流查询工具描述塞进来。这样可以减少大模型在工具选择上的误判。强制输出格式用[TOOL_CALL] ... [/TOOL_CALL]标记这比要求输出纯 JSON 更稳定。因为对话回复本身也是文本纯 JSON 输出容易和多轮文本混淆标记对解析端更友好。3.4 输出解析与回复组织防止大模型戏精附体大模型输出拿到手之后代码要先做一件事——判断里面有没有TOOL_CALL标记。如果有就解析出工具名和参数执行工具然后把工具结果拼接回 prompt让大模型再生成最终回复。我管这一步叫二次生成。二次生成是实现 cua 的关键闭环。没有这一步工具调用完就断链了用户会收到一句已为您查询订单号 30291934当前状态为已签收这种冷冰冰的机器话没有上下文温度。而二次生成的重点是让大模型基于工具的真实返回去组织自然的语气例如结合用户的负面情绪说看到您这单已经签收了如果还没收到货建议先联系一下快递网点确认派件位置。此外解析代码里要有一个兜底逻辑如果大模型的输出里同时包含对话文本和TOOL_CALL标记优先执行工具等工具结果回来再统一生成对话文本。这个逻辑顺序如果不处理好用户会看到你好这里有一个工具调用这种尴尬的半成品。4. 常见问题与排查技巧实录4.1 我在实测中遇到的四个高频问题这套系统我前后跑了三周遇到过的问题比预想中多。挑四个最有代表性的拿出来分享每个都附带排查思路和解决方案。问题一意图识别把我要改收货地址误判成退货申请这个问题的根源是用户表达中同时包含地址和订单两个关键词粗分类模型把注意力放在了订单上。排查时我看了一眼特征权重发现问题出在训练数据里收货地址相关的样本太少。解决方法是补了 200 条包含地址修改/换地址/寄到别处表达的训练样本替换掉原来的分类模型后误判率降了 12 个百分点。问题二多轮对话中槽位被幻读覆盖用户第一轮说了订单号 A123 要退第二轮问查询一下 B456 的物流结果从第二轮开始整个系统的order_id槽位被覆盖成了 B456导致第三轮用户再问刚才那个退货单怎么样时系统拿 B456 去查退货状态直接报错。这事的根源是 DST 设计里没有区分当前任务和跨任务临时输入。我的解决方式是在状态表里加了一个task_stack每个任务有独立的槽位空间只有用户明确切换任务时才创建新的任务栈旧的槽位数据保留但不再参与当前 prompt 拼接。问题三工具接口返回结果被大模型加工过度这是最容易引发信任危机的 bug。我做了个小实验订单查询接口明确返回该订单已签收大模型在回复时多写了一句签收人是洋格格。用户看了一脸懵实际上签收人字段压根没在接口返回里。问题出在 prompt 里没有强调只能引用工具返回的信息。我调整了策略工具执行层返回的摘要用特殊标记包裹并且在 prompt 里单开一段——以下内容是系统真实查询结果必须严格引用不得杜撰补充。修改之后幻觉现象明显减少。问题四大模型长时间多轮对话后失忆这个其实不是大模型真失忆而是长上下文里旧信息被后续内容稀释了。比如用户在第 3 轮提到我是会员能优先退吗第 10 轮再问你们对会员的退货政策是什么模型很难想起来前面聊过会员身份。我的处理是在 DST Service 里加了一个长期记忆区把用户特征型信息会员等级、常用地址、偏好单独存下来在每次构建 prompt 时都注入进去。这样既保留了记忆又不会因为要记录全量对话历史导致 token 成本一路飙升。4.2 问题排查速查表为了大家看得直观我把以上问题和排查思路整理成一个速查表方便实际开发时快速对照。症状可能原因排查方向解决方案用户明确说改地址却被引导退货粗分类训练样本不足查看意图分布和特征权重补充对应意图样本重新微调粗分类模型对话超过 5 轮后信息错乱槽位没有按任务隔离检查 DST 状态表结构引入任务栈区分当前任务和历史任务工具查询结果被模型篡改prompt 未强调信息引用边界回看二段生成日志用特殊标记包裹工具结果并在 prompt 中显式限制某类业务问题频繁转人工工具描述与大模型语义理解不匹配分析工具调用失败记录优化工具 description增加触发场景示例回复内容逻辑通顺但答非所问意图粗分类正确但槽位抽取失误检查槽位填充日志加强第二层校验缺失槽位时主动反问4.3 性能优化与成本控制经验最后聊一个更贴近到一线的实际问题跑一套 cua 到底要花多少钱能不能降下来。实测下来一套 cua 的单日消息成本大头全在大模型 API 调用上。我一开始的做法是每一轮对话都调用两次大模型一次意图槽位抽取一次最终回复跑了一天看账单成本比预估高了 60%压力不小。后来我做了两个优化效果明显。第一个是引入意图置信度阈值。粗分类模型输出一个概率值给每个意图如果概率超过 0.95并且槽位已经完整就直接跳过第一层大模型调用只有槽位缺失或置信度不足时才去调大模型做抽取和追问。这个改动直接砍掉了约 30% 的调用量。第二个是给不同意图分配不同模型规格。像闲聊这种任务用轻量模型就能完成而退货、退款这类涉及实际业务操作的场景用参数较大的模型保证稳定性。按任务的风险等级来决定模型规格是很实用的成本控制策略。关于线上服务的稳定性我最终还在 Tool Executor 外层加了一个熔断开关如果第三方工具接口连续失败超过 5 次就自动切断该工具的调用链路回复用户系统查询暂时不可用请稍后重试或联系人工避免故障时反复重试导致的请求堆积。从决定做 cua 到最终上线我最大的感受是这个方向的门槛不在模型本身而在工程化细节。意图怎么拆、状态怎么管、工具结果怎么防篡改、成本怎么控制每一步都是一堆看似不起眼但决定成败的细节。如果大家准备在自己的项目里落地 cua我建议先从单业务场景做起比如只做物流查询把意图—槽位—工具—回复这条链路完全走通再加下一个业务。不要一上来就铺五个业务因为每一个新增业务都会放大状态管理和工具调度的复杂度也容易让排查问题的难度指数上升。这套实现我目前还在继续迭代后续计划把 DST 模块迁移到 Redis 集群做更细粒度的用户画像槽位——比如把高价值用户优先处理这种规则接进工具决策层。这个方向的可玩性还有很多希望这篇能帮大家少踩一些我踩过的坑快速把 cua 跑起来。