"CUA"这三个字母,放在不同语境里意思差得远了。有人看到它想到某个业务系统的内部编码,有人觉得是某个新出的网络热词缩写。我今天要分享的CUA,全称是Conversational User Assistant,中文叫"对话式用户助手",是我花了大半个业余周末从零搭起来的一个个人项目。它要解决的事情其实特别朴素:让AI不只会聊天,还能直接帮我调用本地工具干活——读文件、跑脚本、整理数据、把最终结果塞回对话里。
这篇文章不是讲概念,而是把一个Agent类小工具从立项、选型、实现到踩坑的完整过程摊开来讲。如果你正打算自己写一个带工具调用能力的对话助手,或者对Function Calling的实际落地感兴趣,那这篇应该能帮你少走不少弯路。我会把代码、设计决策和翻车现场都放出来,希望能给你一点参考。
1. 我为什么放着现成的AI助手不用,非要自己搞一个CUA
1.1 现成工具的"最后一公里"问题
先说结论:绝大部分通用AI工具,擅长"聊",不擅长"干"。
举个最典型的场景。我经常要整理一批本地日志文件,提取错误码、统计出现频次、生成一个表格。用通用AI工具,我能得到一段非常正确的Python脚本,然后呢?我还得自己把脚本保存成文件、装依赖、跑起来、看到报错再回头改一遍。整个过程下来,可能比我自己手写还慢。
我当时的需求是:我直接在对话框里说"帮我把今天的nginx错误日志按error_code分组统计,输出到report.csv",然后它自己完成读文件、写代码、执行、返回结果,并告诉我"已经生成好了,一共识别出12种错误码,最多的是504"。
这就是"最后一公里"的差距。通用助手把话说到位,但活儿得你自己干。我想要的不是一个嘴强王者,而是一个真的能把事情办完的助手。
1.2 CUA立项时的三条设计原则
想清楚需求之后,我给自己定了三条原则,整个项目都是围绕这三条展开的:
第一条,一切能力都是工具,对话只是入口。用户不关心你内部怎么规划,只关心"我提了个需求,你能不能完成"。所以文件读取、命令执行、数据统计这些能力,全部封装成可被模型调用的工具函数。
第二条,默认执行,不是默认建议。这是和通用AI助手最大的区别。普通的对话模型收到指令后,倾向于给建议;CUA收到明确指令后,会直接编排工具去执行,除非指令有歧义或者缺参数,才追问用户。
第三条,全程可审计。每次工具调用,谁调的、传了什么参数、返回了什么结果,全部落日志。这不是为了炫技,是Agent应用必须有的底线——模型是会犯错的,没有日志就谈不上排查。
1.3 为什么不做成GUI或者浏览器插件
立项的时候很多人问我:为什么不做个好看的界面?做成浏览器插件不是更方便?
我的考虑是这样:作为一个个人项目,第一版的核心永远是快速验证核心链路。GUI会消耗大量时间在布局、交互、状态管理上,浏览器插件则要处理权限模型和跨域问题,这些都是噪音。相比之下,CLI加本地服务是最短路径:
- CLI负责交互输入和流式输出展示,几十行代码就能做得很顺手;
- FastAPI起一个本地服务,统一处理大模型API通信、工具调用和日志;
- 工具函数以插件形式放在独立目录,加功能不用动主程序。
等核心链路跑通了,再考虑套一层Web界面也不迟。很多人做项目死在第一步,不是因为功能不够强,而是因为一开始就把壳做得太重。
2. 技术选型的完整思考:模型、框架与通信架构
2.1 大模型选型:工具调用能力是底线
CUA的核心是模型得会"调用工具",所以选型时我重点考察了模型的Function Calling能力。市面上的模型不少,但我最后收敛到一条标准:能否稳定输出结构化的工具调用请求。如果模型经常把参数格式写错,或者该调用工具的时候偏偏自己编一个答案,那后面的一切都无从谈起。
我当时给自己列了个表格,几个候选模型的对比大概是这样的:
| 对比维度 | 模型A(通用强、无工具接口) | 模型B(支持工具调用、生态成熟) | 模型C(工具调用需要额外提示词) |
|---|---|---|---|
| 工具调用稳定性 | 不支持,只能靠提示词硬套 | 稳定,原生支持 | 一般,经常漏参数 |
| 多工具并行 | 不支持 | 支持 | 支持但不稳定 |
| 上下文长度 | 很长 | 中等偏上,够用 | 中等 |
| 成本/千token | 高 | 中 | 低 |
| 生态与文档 | 好 | 好 | 一般 |
综合考虑后,我选了模型B。原因有三个:一是原生工具调用接口省去了很多提示词工程上的折腾;二是并行工具调用很稳,这对效率影响很大;三是生态好,碰见问题能找到现成答案。成本虽然比模型C高,但在工具调用场景下,模型C失败一次重试的token消耗,往往比直接用好模型还贵。
2.2 对话管理:为什么不用现成Agent框架
当时我也研究了一轮市面上的Agent框架,它们确实很强大,抽象层级很高,什么规划、记忆、多智能体协作全都给你安排好了。但我最后没有用,原因说出来可能有点"老派":抽象层次太高,出了问题不好排查。
框架帮你封装了太多东西,一旦模型行为不符合预期,我得先花大量时间去理解框架的运行机制,再定位是自己代码的问题还是框架的坑。对于一个以"搞懂原理"为目标的个人项目,这太不划算了。
所以我选了一条更笨、但透明的路:自己管理消息列表,每次直接调用模型的chat completions接口。核心数据结构就是一个数组,角色有三种:system、user、assistant,以及工具调用产生的tool角色消息。每一轮对话的本质,就是在数组后面追加消息,然后把整个数组发给模型。
messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": "帮我把今天的错误日志统计一下"}, # 每轮模型回复、工具结果都追加在后面 ]这个设计最大的优点就是透明。我能完整看到模型看到的上下文,每一步都可控。缺点也很明显,就是得自己处理很多细节——比如上下文超长、工具调用结果的组装、并发安全。但对我来说,这些细节恰恰是这个项目最有价值的部分。
2.3 整体架构:CLI + 本地服务 + 插件目录
整个CUA的架构分三层:
- 客户端:一个Python写的CLI程序,负责读取用户输入、实时打印模型的流式回复。没有用复杂的前端框架,就是标准库加一个HTTP库。
- 服务端:FastAPI服务,承载核心逻辑。包括模型API调用、工具注册与执行、对话历史管理。所有工具调用都有日志,方便事后审计。
- 工具层:一个
tools/目录,每个工具函数都以标准格式注册。后续要加能力,就往这个目录里放个新文件。
选FastAPI而不选Flask,主要是冲着两点:一是异步支持好,二是有自动生成API文档。调试的时候打开/docs页面,可以直接测试接口。这个体验在开发阶段非常舒服。
3. 核心实现拆解:从普通聊天到能调用工具
3.1 系统提示词:给模型立规矩
很多人在做Agent应用时低估了系统提示词的作用,以为模型天然就懂怎么调用工具。实际上,如果不把工作方式讲清楚,模型会频繁出现"该调工具时不调"或者"工具报错了还硬要编个结果"的毛病。
我给CUA写的系统提示词,核心就几段话,但每句话都是踩坑踩出来的:
你是一名对话式用户助手,名称是CUA。你的核心工作是帮助用户完成实际操作任务,而不仅仅是回答问题。 当用户提出任务时,遵循以下流程: 1. 判断是否需要调用工具。如果需要,直接调用合适的工具,不要解释为什么要调用。 2. 如果多个操作之间没有依赖关系,尽量并行调用工具以提高效率。 3. 等待工具执行结果后,用简洁的语言向用户汇报结果。不要复述工具的输入参数。 4. 如果工具返回错误,先分析错误原因,尝试修正参数后重试,最多重试2次。重试仍失败则如实告知用户失败原因。 5. 不要编造工具没有返回的数据。所有结论必须基于工具的实际返回。这里面最关键的是第5条。早期的CUA经常一本正经地编造统计数据,明明工具返回了12行数据,模型能给你总结出18个分类。这句"不要编造工具没有返回的数据"加上去之后,幻觉问题大幅下降。
3.2 工具注册机制:几行代码接入一个新技能
工具注册是Agent应用的骨架。CUA的工具注册我用了一个装饰器方案,好处是开发者加新工具时,只需要写函数本身,函数签名自动会转成模型需要的JSON Schema。
# tool_registry.py import inspect import json from functools import wraps _TOOL_REGISTRY = {} def tool(name=None, description=""): def decorator(func): tool_name = name or func.__name__ signature = inspect.signature(func) parameters = {"type": "object", "properties": {}, "required": []} type_mapping = { int: "integer", float: "number", str: "string", bool: "boolean", } for param_name, param in signature.parameters.items(): if param_name in ("self", "kwargs", "args"): continue json_type = type_mapping.get(param.annotation, "string") parameters["properties"][param_name] = {"type": json_type} if param.default is inspect.Parameter.empty: parameters["required"].append(param_name) _TOOL_REGISTRY[tool_name] = { "function": func, "schema": { "type": "function", "function": { "name": tool_name, "description": description, "parameters": parameters, } } } @wraps(func) def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper return decorator用起来是这样的:
# tools/file_tools.py from tool_registry import tool import csv ... @tool(description="读取指定路径的CSV文件,返回所有行") def read_csv(file_path: str) -> dict: with open(file_path, "r", encoding="utf-8") as f: reader = csv.DictReader(f) rows = list(reader) return {"row_count": len(rows), "rows": rows[:50]}函数名字就是工具名,参数注释和类型注解会被转成模型识别的参数结构。这个设计让新增一个工具的成本压缩到两件事:写函数体、加装饰器。没有额外注册文件,也就不会出现"注册了但没实现"或者"实现了但没注册"这种低级问题。
3.3 工具调用循环:模型和工具之间的握手协议
工具调用的核心逻辑,本质上是一个while循环。流程是这样的:
- 把messages发给模型;
- 如果模型返回了tool_calls,就说明它想调用工具;
- 遍历这些tool_calls,执行对应的函数;
- 把函数执行结果作为
role=tool的消息追加回消息列表; - 带着新消息再问模型;
- 直到模型返回普通文本(不再请求工具),作为最终答复输出给用户。
代码核心大概长这样:
def run_conversation(user_input): messages.append({"role": "user", "content": user_input}) while True: response = client.chat.completions.create( model=MODEL_NAME, messages=messages, tools=[t["schema"] for t in _TOOL_REGISTRY.values()], ) msg = response.choices[0].message messages.append(msg) if not msg.tool_calls: return msg.content for tool_call in msg.tool_calls: tool_name = tool_call.function.name args = json.loads(tool_call.function.arguments) # 记录日志,便于审计 logger.info("[TOOL] %s args=%s", tool_name, args) result = _TOOL_REGISTRY[tool_name]["function"](**args) messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": json.dumps(result, ensure_ascii=False), })这里有个细节值得单独说:所有工具返回值都统一用JSON字符串格式。为什么?因为模型最擅长处理结构化文本,JSON天然有key-value结构,模型理解起来几乎没有歧义。如果工具返回的是纯文本或者HTML,模型在二次加工时容易抓错重点。
3.4 上下文管理:长对话不跑偏的土办法
Agent跑一段时间后一定会遇到上下文爆炸的问题。CUA的做法比较朴素,两层机制:
第一层,滑动窗口裁剪。当消息条数超过阈值(比如40条)时,把最早的一部分历史消息丢弃。丢弃不是无脑丢,是先判断这些消息里有没有还没被总结的重要信息。
第二层,关键信息摘要。如果前面的内容里有用户明确说过的偏好,比如"以后统计报表都用CSV格式",CUA会把这些偏好抽取出来,持久化到一个独立的preferences文件里。每次会话开始时把摘要注入系统提示词。这样即使历史窗口被裁剪,核心偏好也不会丢。
这个方法比不上那些用向量数据库做长期记忆的方案高级,但对付个人工具场景已经足够,而且实现成本极低、完全可控。
4. 踩坑实录:三次让我想删库重写的故障
4.1 并行工具调用的结果错乱问题
现象:当模型一次要求并行调用多个工具时,返回的结果出现错位。比如工具A是查天气,工具B是查日历,最后天气的结论里混进了日历的数据。
排查链路的起点是日志。我打开工具调用日志,发现模型输出的tool_calls数组里每个元素都有独立的id和index,但我第一版代码是这么写的:
# 错误写法:列表索引和结果错位 for i, tool_call in enumerate(message.tool_calls): result = run_tool(tool_call.function.name, tool_call.function.arguments) messages.append({ "role": "tool", "tool_call_id": message.tool_calls[i].id, # 这里可能和本次不是同一个 "content": json.dumps(result) })表面看没什么问题,i从头遍历到尾,message.tool_calls[i]和tool_call确实是同一个。但真正的问题出现在一次调用里嵌套多层工具时:内层的工具结果追加进messages后,如果后续代码不小心引用了同一个message.tool_calls对象去取id,就会因为顺序错位识别错。更隐蔽的是,有些极端情况下API返回的tool_call顺序和代码请求顺序不一致,用索引去关联结果必然出错。
修复很简单:严格用tool_call.id去绑定返回结果,而不是靠索引或者名字。
# 正确写法 for tool_call in message.tool_calls: result = run_tool(tool_call.function.name, tool_call.function.arguments) messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": json.dumps(result) })这个坑让我意识到一个问题:Agent应用里,凡是把"数组顺序"当作"关联关系"的代码,都是定时炸弹。工具调用之间的关系,应该使用API显式给出的ID来绑定。
4.2 流式输出与工具调用结果的时序竞态
为了让输出体验更好,我给模型回复做了流式输出。结果第二周就来了个诡异bug:有时候用户看到一半的文字,然后突然冒出工具调用痕迹,或者工具结果还没回来,模型就开始"编造"工具执行后的结果了。
排查过程花了我一整个下午。客户端日志显示,流式模式下API返回的内容被拆成了很多个chunk,每个chunk里的delta字段有时携带content,有时携带tool_calls。问题出在我流式聚合的逻辑上:我把收到的内容实时拼接并显示给用户,但遇到携带tool_calls的chunk时,没有统一收集,而是边收边往messages里面追加。这导致tools还没完整组装,模型已经拿了一个残缺的tool_calls对象去继续生成回复。
修复方案是:流式聚合阶段不做任何执行决策。先把所有chunk收集完整,在本地拼接出完整的message对象,统一再走一次判断:如果是tool_calls就执行工具;如果是content就显示给用户。简单说就是"先收集,再决策"。
# 伪代码示意 collected_chunks = [] for chunk in stream_response: collected_chunks.append(chunk) full_message = assemble_message(collected_chunks) if full_message.tool_calls: execute_tools(full_message.tool_calls) else: print(full_message.content)踩完这个坑,我又仔细翻了一下不同模型的流式协议说明,发现各家在chunk粒度上确实有细微差异。有的模型会在同一个chunk里同时携带content和tool_calls的开头,有的则完全分开。这也算是个经验:流式模式下不要对协议做太多假设,统一采用"先收集后决策"永远是稳妥的。
4.3 上下文爆炸:token消耗从0.5倍涨到5倍
CUA上线跑了一周,后台账单让我有点吃惊:token消耗环比涨了接近5倍。我第一反应是使用量增加了,但查了会话记录之后发现并没有那么多新对话。
问题出在工具调用结果的处理上。第一次实现时,我图省事,没有对工具返回结果做任何截断。后果是:如果一个工具返回了1万行CSV数据,这1万行会原封不动塞进messages,再送给模型。一次倒还好,问题是CUA在统计类任务里经常要循环调用同一个工具多次,于是上下文以肉眼可见的速度膨胀。再叠加错误处理分支里"重试逻辑写得不严谨"导致的重复请求,token消耗就这么被堆上去了。
修复做了三件事:
- 限制工具返回长度:所有工具返回给模型的内容,超过200行的截断为"共N行,前200行如下...更多内容可通过指定参数查询";
- 增加失败终止策略:同一个工具连续失败两次,不再自动重试,而是直接结束本轮对话,把错误信息汇报给用户;
- 定时清理消息列表:会话中超过30条的旧消息自动触发压缩。
修复后再跑了一周,token消耗回落到了正常区间的1.2倍左右,而且对话质量并没有明显下降。这里我学到的一个通用经验是:Agent应用的token成本,大多数时候不是被"对话长度"吃掉的,而是被"工具返回结果"吃掉的。控制好工具返回的体量,成本问题就解决了一大半。
5. 实测效果:我的工作流到底有没有被改变
5.1 三类高频任务的耗时对比
项目跑稳定后,我做了个小范围的统计测试,拿自己日常三种高频任务做对比:日志错误统计、周报素材整理、CSV数据筛选。
| 任务 | 纯手工 | 传统脚本 | CUA(对话式操作) |
|---|---|---|---|
| 统计一份200MB nginx错误日志的错误码分布 | 约15分钟 | 约5分钟(前提是已经有脚本) | 约2分钟 |
| 把一周的零散工作记录整理成结构化周报 | 约25分钟 | 不适用 | 约4分钟 |
| 从10万行CSV里筛选并汇总指定条件的数据 | 约20分钟 | 约3分钟(写一次脚本) | 约3分钟 |
这个测试结果说明了一个很关键的事:CUA并不能在计算层面秒杀掉手写脚本。在CSV筛选任务里,手写脚本和CUA耗时几乎一样,因为底层都是Python在处理。CUA真正的优势体现在两种场景里:一是任务本身随机性很强,每次参数都不同,不值得专门写脚本;二是任务链路很长,CUA能自己完成"读数据、处理、生成报表、保存文件"整个链路。
5.2 真正提升效率的,不是"智能",而是"衔接"
用了半个多月,我对"效率提升来自哪里"有了更清醒的认识。单看模型智商,CUA和通用AI助手没有本质差别。真正提升效率的,是CUA把整个工作链路"衔接"起来了:它能把中间产物写到临时文件,能把上一步的输出直接喂给下一步,能把最终结果存成指定格式。
比如"生成周报"这个任务,通用AI能给出一篇非常好的周报模板,但它不知道你这周到底发生了什么。CUA的做法是:先调read_notes读你的工作记录,再调summarize提炼关键事项,接着调write_file把周报写进指定路径,最后告诉你"已生成完成"。每个环节的数据不用你手动搬。这个"衔接感"才是助手这类产品存在的意义。
5.3 实测200次工具调用的稳定性数据
为了摸清CUA的可靠性,我连续几天让它在模拟任务里跑工具调用,记录了两组数据。第一组是成功率:在200次实际工具调用中,一次成功的占比约74%,经过一次参数修正后成功的占比约18%,完全失败的占比约8%。失败原因主要集中在模型传错了参数类型,比如把一个字符串传给了要求整数的参数。
第二组是失败后的重试情况。早期版本在工具返回异常时会让模型自己判断是否重试,实测下来模型经常"执着"地按原参数重试两三次,无意义地消耗token。后来我改成工具层主动返回错误原因,并且约定"涉及参数异常时,必须修改参数后再重试,否则终止"。这一小改动,让无效重试的比例下降了六成。
这个数据不算惊艳,但对个人工具来说已经可用了。而且它给了我很明确的方向:如果未来想让CUA更可靠,重点不是换更强的模型,而是完善工具层的参数校验和错误信息反馈。
6. 我沉淀下来的开发习惯,以及CUA的下一步
6.1 好用的工具函数返回格式,从第一天就要坚持
我开发CUA时最大的一个习惯,就是所有工具函数的返回值统一用JSON,且约定三个顶层字段:success、data、error。不管底层操作是什么,这个结构不破。
{ "success": true, "data": {"row_count": 100}, "error": null }这样做的好处,是模型在大量工具间切换时,不需要重新学习每个工具的返回格式。它只要看success就知道操作成没成,看data拿数据,看error定位问题。这个约定,建议所有做Agent应用的朋友都从项目第一天就定下来,不然后面改结构会牵连所有工具和所有历史对话。
6.2 为工具调用写单元测试,别让模型背锅
大多数工具函数是纯逻辑,完全适合写单元测试。我给CUA的所有工具函数都补了基础的用例——正常输入、边界输入、异常输入三类。这看起来费时间,但后期收益巨大。因为模型的行为有随机性,当你发现"某次会话崩了"时,需要立刻判断到底是模型决策错了,还是工具本身实现有问题。如果工具函数经过充分测试,排查范围就能瞬间缩小一半。
6.3 CUA的后续规划:插件机制和多模型适配
以目前的架构,CUA再往前走有两个方向比较明确。一个是插件机制:采用"目录+manifest"的形式,一个插件就是一个目录里面放一个描述文件和一个工具实现文件,通过CLI命令一键安装。这样别人贡献新技能时不需要改主程序代码,降低了协作门槛。
另一个是多模型适配层。现在代码里直接调了一家模型的SDK,虽然足够用,但被厂商锁定的风险始终存在。我打算把模型调用抽象成统一接口,底层用适配器兼容不同家协议。这样将来哪家服务不稳定、或者出现了性价比更高的模型,切换成本就能降到最低。
在这两个大方向落地之前,我觉得CUA这个项目已经完成了一个小工具该做的事:它让我在日常工作里省下了时间,也让我把对话式Agent的底层机制摸了个透。如果你也在研究类似的东西,我的建议是:别急着追求复杂的框架和花哨的功能,先把"对话-工具-结果"这条主链路跑通,把工具调用这个地基打牢。地基稳了,后面的功能都是水到渠成的事。