从零构建CUA:让大模型调用本地工具的实战指南
CUA这三个字母放在不同语境里意思差得远了。有人看到它想到某个业务系统的内部编码有人觉得是某个新出的网络热词缩写。我今天要分享的CUA全称是Conversational User Assistant中文叫对话式用户助手是我花了大半个业余周末从零搭起来的一个个人项目。它要解决的事情其实特别朴素让AI不只会聊天还能直接帮我调用本地工具干活——读文件、跑脚本、整理数据、把最终结果塞回对话里。这篇文章不是讲概念而是把一个Agent类小工具从立项、选型、实现到踩坑的完整过程摊开来讲。如果你正打算自己写一个带工具调用能力的对话助手或者对Function Calling的实际落地感兴趣那这篇应该能帮你少走不少弯路。我会把代码、设计决策和翻车现场都放出来希望能给你一点参考。1. 我为什么放着现成的AI助手不用非要自己搞一个CUA1.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(nameNone, 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, encodingutf-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执行对应的函数把函数执行结果作为roletool的消息追加回消息列表带着新消息再问模型直到模型返回普通文本不再请求工具作为最终答复输出给用户。代码核心大概长这样def run_conversation(user_input): messages.append({role: user, content: user_input}) while True: response client.chat.completions.create( modelMODEL_NAME, messagesmessages, 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_asciiFalse), })这里有个细节值得单独说所有工具返回值都统一用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的底层机制摸了个透。如果你也在研究类似的东西我的建议是别急着追求复杂的框架和花哨的功能先把对话-工具-结果这条主链路跑通把工具调用这个地基打牢。地基稳了后面的功能都是水到渠成的事。