cua对话式用户代理从概念到落地:意图识别与工具调用的工程实践
2026/9/23 5:35:19 网站建设 项目流程

最近在开发者圈子里频繁看到"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_idreason这两个槽位。如果槽位缺失,它会主动反问用户补齐信息。

这样拆的好处有三个:模型各司其职,故障点容易定位,而且当你需要新增一个意图类别时,不必重新调试一整条链路。

关于意图识别,我再分享一个踩过的坑:用户说的词往往跟业务后台的词不一致。比如业务后台叫"退货退款",用户说"我要退钱""我不想买了,直接帮我取消""这个怎么退啊",这些表达看起来都是退货,但语义上其实是有细微差别的。我最终的做法是在第一层意图标签里加了一个"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_nametool_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_ascii=False)}" 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 = f"https://api.example.com/order/{order_id}" for attempt in range(MAX_RETRY): try: resp = requests.get(url, timeout=TOOL_TIMEOUT) if resp.status_code == 200: data = resp.json() return { "status": "ok", "summary": f"订单 {order_id} 当前状态为 {data['status_text']}" } else: raise Exception(f"HTTP {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 跑起来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询