首页 / 资讯中心 / 文章详情

从聊天机器人到Tool Server:LLM工具调用的完整落地实践

从聊天机器人到Tool Server:LLM工具调用的完整落地实践 ★ FEATURED ARTICLE
我最初写那个机器人真就只是想让它陪群里的朋友聊天解闷。QQ群里有人喊一句在吗它能回一句我一直都在问天气它能去查个API偶尔讲个冷笑话大家哈哈一乐也就过去了。真正让我决定把它往tool server方向上改造的是一次极其尴尬的翻车——同事在群里说这个月的稿费汇总表能整理一下发我吗它憋了半天回了一句我这边看不到您的文件呢。那一刻我意识到一个只会输出文本的聊天机器人本质上跟一个高级复读机没有区别它不产生任何实际的动作自然也就解决不了任何实际问题。后来我花了大概两周时间把这个能聊天但不能干活的机器人拆成了一个对话编排层 工具执行层的结构。说白了就是让LLM只负责理解意图、规划步骤真正干活的是挂在它身上的一堆工具函数这些工具统一由一个服务暴露出去那就是标题里说的tool server。这篇文章不讲大道理主要是记录我这次改造过程中完整的设计思路、核心机制拆解、可复现的Python实现以及那些文档里永远查不到的坑。无论你是正在接QQ、钉钉、飞书机器人还是单纯想研究Function Calling怎么落地这篇文章应该都能给你一些可以直接抄作业的东西。1. 起因聊天机器人怎么就走上了工具服务器的路1.1 只会聊天的机器人离有用还差一大截先说说原来的形态。我最早那版机器人接的是QQ社区的协议框架用了NoneBot2模型走的是OpenAI兼容的Chat接口。消息流程很简单群里有人艾特机器人它收到文本把文本丢给大模型模型生成一段回复文本机器人把文本发回群里。整个过程就是文本进、文本出中间没有任何状态也没有任何外部系统的交互。这种形态在闲聊场景够用但一旦碰到真实需求立刻露馅。比如帮我查一下昨天测试环境的报错量、把excel里这个月的数据总结一下发到群里、提醒我下午三点开会。这些请求的共同点是它们不要求模型知道什么而要求模型做到什么。读文件、查数据库、发消息、设闹钟全是动作不是话术。文本输入输出这个模型根本产生不了动作它只能在语气上表演我已经查了或者干脆诚实地说我不会。所以那次翻车其实不算模型的错是我架构的错。我把一个需要手脚的机器人硬生生做成了只长嘴的鹦鹉。要让它有用必须给它接上工具而且不是那种写死在代码里的if-else工具是能让模型自己根据用户需求动态挑选、动态调用的工具集合。这就是Function Calling登场的时机。1.2 tool server到底是个什么东西在讲实现之前先把概念捋清楚。tool server字面意思是工具服务器但这不是一个标准化的名词不同人讲的可能不是一回事。我一直以来的理解是把一批可以被外部系统调用的能力函数集中部署、统一注册、通过接口暴露出去的服务就是tool server。它跟普通REST API服务最大的区别在于普通API服务的调用方是人或前端页面接口设计以人机交互习惯为准tool server的调用方是大模型接口的入参、出参、描述信息全都得按大模型的思维习惯来设计。换句话说一个tool server是给LLM用的API网关。为什么要单独拆一个服务出来而不是把工具函数直接写在聊天机器人进程里我这次改造过程中体会特别深。最开始工具函数只有两三个写在同一个进程里确实省事。但后面工具一多问题就来了聊天机器人进程会频繁热更新工具函数也跟着一起重启有些工具是用别的语言写的Python这边调不动团队其他人也想复用这些工具但谁都不愿意去动机器人那摊代码。把这些工具抽成独立服务之后工具可以单独迭代、单独部署、单独做权限控制机器人只管对话这一件事。这正是我觉得从聊天机器人变成tool server这个转变最本质的地方——把能力从对话流程里解耦出来。2. 核心机制模型怎么知道该用哪把工具2.1 Function Calling一轮交互里发生了什么要让模型调用工具靠的不是魔法而是一套已经标准化的协议也就是我们常说的Function Calling或者Tool Calling。目前主流的模型API基本都支持这个能力OpenAI、通义、智谱、Claude等都有对应实现交互逻辑大同小异。我以最常见的OpenAI兼容格式为例拆解一下。整个过程可以理解成一个三幕剧第一幕客户端发起请求时除了带上用户消息之外还可以额外带一个tools参数。这个参数是个数组里面装的是工具声明每个声明用JSON Schema描述清楚这个工具叫什么、干什么用、参数有哪些、每个参数是什么类型。模型接收到这些声明后会把这些工具记在心里作为本次对话的可选行动项。第二幕模型在生成回复的时候如果它觉得用户的需求需要调用某个工具就不会直接输出文本而是返回一个tool_calls字段。这个字段里包含了模型认为应该调用的工具名以及一组JSON格式的参数——注意这里的参数是模型根据用户的原话和工具的描述自己猜测出来的它并没有真正执行任何东西。第三幕程序拿到模型返回的tool_calls自己在本进程里或者去远程tool server真实地执行这个函数然后把函数的执行结果作为一条roletool的消息连同之前的消息上下文一起重新发给模型。模型看到工具返回的真实结果之后再组织语言回复用户比如已拉取到最新数据汇总如下。整个链路里模型只负责决策执行动作永远发生在模型之外。这个设计非常关键——模型永远不接触真实的文件系统、数据库、网络请求它只会建议程序去做程序来决定是否信任、如何执行。2.2 工具声明写不好模型就开始瞎猜工具声明的质量直接决定模型调用工具的准确率。我最初踩过一个特别典型的坑我把一个工具的描述写成读取Excel文件数据参数只有file_path一个字段类型是string然后什么都没写。结果用户的问法稍微绕一点比如帮我看一下上个月那个报表模型就开始瞎传参数甚至传出file_path: 上个月的报表这种值来。项目里文件路径是有固定规则的它不知道就只能凭空编。后面我把工具描述重写成了这个样子{ type: function, function: { name: read_excel_data, description: 读取指定Excel文件的指定工作表内容返回前N行数据的Markdown表格。文件路径必须是data目录下的相对路径例如 sales/2024/12.xlsx不允许使用绝对路径。, parameters: { type: object, properties: { file_path: { type: string, description: Excel文件的相对路径相对于data目录例如 sales/2024/12.xlsx }, sheet_name: { type: string, description: 工作表名称不传则读取第一个工作表, enum: [sheet1, sheet2, 汇总] }, row_limit: { type: integer, description: 返回的最大行数默认50, minimum: 1, maximum: 200 } }, required: [file_path] } } }写清楚之后模型传参的错误率立刻降了一大截。我总结下来有三条经验第一描述里一定要写明工具的边界比如允许什么路径不允许什么操作第二能用enum枚举的参数尽量枚举这等于直接告诉模型你只能选这几个值幻想空间小很多第三每个参数都要加description哪怕对人来看着很明显——模型没有常识你不告诉它它就不知道。2.3 多工具调用和工具结果回传还有一点很多人刚开始接触时会懵一次模型响应里可能不止一个tool_calls而是一整个数组。比如用户说把12月的销售汇总发到群里模型可能同时选择两个工具先read_excel_data读数据再send_dingtalk_push发消息。在OpenAI兼容格式里这两个调用会同时出现在一个响应里程序需要遍历这个数组逐一执行然后把结果逐一回传。回传的格式也有讲究。每一条工具结果消息都要带上原始调用对应的tool_call_id这是必须的不然模型没法把结果和调用对上号。整个多轮工具调用的循环我习惯写成下面这个结构循环里最多跑5轮因为正常一个任务两三轮就能完成超过5轮基本就是模型在死循环或者逻辑出问题了这时候直接截断返回避免浪费token和卡死。messages [{role: user, content: 把销售表前10行发到群里}] for round_idx in range(5): response client.chat.completions.create( modelMODEL_NAME, messagesmessages, toolsTOOL_SCHEMAS, tool_choiceauto, ) msg response.choices[0].message if not msg.tool_calls: return msg.content messages.append(msg) for tc in msg.tool_calls: result execute_tool(tc.function.name, tc.function.arguments) messages.append({ role: tool, tool_call_id: tc.id, content: json.dumps(result, ensure_asciiFalse), }) return 抱歉这个任务步骤过多我已主动终止。这段代码是我整条链路的核心骨架后面所有功能都是围着它长的。这里有个细节值得说一下工具返回结果我统一用json.dumps转成字符串再塞进content因为模型API的消息content字段是文本类型结构化的对象必须序列化。序列化时务必加上ensure_asciiFalse不然中文全变成\uXXXX转义序列模型读起来费劲回复质量也会跟着下降。3. 完整落地Python把Excel数据通过钉钉机器人推到群聊3.1 需求链路梳理硬核理论讲完来一个完完整整能跑通的例子。需求就是标题里那个场景用户对QQ机器人说把9月份的销售数据整理一下发群里机器人先是理解意图、规划动作然后调用工具读取Excel再把数据格式化调用钉钉的群机器人Webhook推送一条Markdown消息到群里。整条链路看起来长拆开其实只有四段先是消息入口QQ群里用户发的文本被我们的机器人框架捕获然后进入LLM编排模型根据工具声明决定要调用哪两个工具接着执行层跑起来Excel读取、数据格式化、钉钉推送这三个动作依次完成最后机器人把执行结果整理成一段友好的文本回复用户比如已推送至钉钉群请注意查收。这里有人可能会问为什么要QQ机器人收到消息然后用钉钉机器人推到钉钉群跨平台是不是有点绕这其实是真实职场很常见的情况——老板在QQ群里下指令但日常工作群在钉钉。跨平台反而让这个例子的通用性更强消息从哪进来其实不重要重要的是工具层能接住各类IM平台的发送能力。3.2 用FastAPI把工具包成HTTP服务这个例子里的钉钉推送其实直接内嵌在机器人进程里也能做但前面说了这次改造的目标是工具服务化所以我用FastAPI把工具包成了一个独立HTTP服务。FastAPI这东西的好处是自动生成接口文档、参数校验方便、而且异步支持好Python圈子里做轻量服务基本首选它。我建了一个tool_server.py最基本的骨架长这样from fastapi import FastAPI, HTTPException from pydantic import BaseModel app FastAPI(titleTool Server) class SendDingTalkRequest(BaseModel): webhook_url: str content: str title: str 数据推送 class ReadExcelRequest(BaseModel): file_path: str sheet_name: str | None None row_limit: int 50 app.post(/tools/excel/read) def api_read_excel(req: ReadExcelRequest): try: return {success: True, data: read_excel_data(...)} except Exception as exc: raise HTTPException(status_code400, detailstr(exc)) app.post(/tools/dingtalk/send) def api_send_dingtalk(req: SendDingTalkRequest): ...然后uvicorn tool_server:app --host 0.0.0.0 --port 8300一启动工具就暴露成HTTP接口了。机器人进程那边只需要用requests去调用。这样做的好处有三个第一工具的执行日志、报错监控可以在服务这边统一做跟对话逻辑彻底分开第二这些工具完全可以被其他业务系统复用不只是机器人能调第三工具出了问题不会把聊天进程也拖崩。3.3 实现Excel读取和钉钉推送两个核心工具先看Excel读取。用pandas读Excel是标准操作但直接把DataFrame返回给模型不合适模型处理长二维表很吃力而且容易截断。我习惯先把DataFrame转成Markdown表格格式规整、token占用也比JSON少。import pandas as pd def read_excel_data(file_path: str, sheet_name: str | None None, row_limit: int 50): df pd.read_excel(file_path, sheet_namesheet_name) df df.head(row_limit) return df.to_markdown(indexFalse)这里要注意几个点。一是sheet_name参数pandas支持传工作表名称不传就默认读第一个工作表二是row_limit必须限制有人让模型读Excel模型传一个5000大表直接能把上下文撑爆三是文件路径越界问题服务端必须校验file_path只能落在白名单目录里这个我在后面安全小节里详细说。钉钉推送那边更简单。钉钉群自带的自定义机器人功能加完之后会给你一个Webhook地址向这个地址POST一段JSON就能往群里发消息。支持text、markdown、link等几种消息类型我们用markdown因为Excel转出来的表格在Markdown格式下显示效果最好。import requests def send_dingtalk_message(webhook_url: str, content: str, title: str 数据推送): payload { msgtype: markdown, markdown: { title: title, text: content, }, } resp requests.post(webhook_url, jsonpayload, timeout10) resp.raise_for_status() return {status_code: resp.status_code, response: resp.text}requests.post一定要加timeout。我第一次没加钉钉接口偶尔卡一下机器人进程就在那里干等群里的体验就是机器人没反应。设置10秒超时之后最坏情况也就是工具报个超时错误对话还能继续兜底。3.4 把工具接入LLM让机器人自己决定怎么干工具服务有了钉钉Webhook有了最后一步就是把这两个工具的声明塞进LLM请求里让模型自己决定什么时候调它们。工具声明写成数组和前面的JSON Schema格式保持一致TOOL_SCHEMAS [ { type: function, function: { name: read_excel_data, description: 读取data目录下的Excel文件返回指定工作表前N行数据的Markdown表格, parameters: { type: object, properties: { file_path: {type: string, description: 相对于data目录的文件路径如 sales/2024/09.xlsx}, sheet_name: {type: string, description: 工作表名不填则读取第一个工作表}, row_limit: {type: integer, description: 最大返回行数默认50} }, required: [file_path] } } }, { type: function, function: { name: send_dingtalk_message, description: 向钉钉群发送一条Markdown消息, parameters: { type: object, properties: { webhook_url: {type: string, description: 钉钉自定义机器人的Webhook地址}, content: {type: string, description: Markdown格式的消息内容}, title: {type: string, description: 消息标题} }, required: [webhook_url, content] } } } ]然后请求LLM时把toolsTOOL_SCHEMAS带上在前面那张骨架代码里循环执行即可。实测下来只要用户说的话里有excel表格销售数据这些关键词模型几乎百分百会选中read_excel_data只要出现发到群里推送给发钉钉这些指令send_dingtalk_message就会被选上。而且模型还会自动规划顺序先读表再推送这个链路我不用写一行if-else全靠两个工具声明就做完了。3.5 多轮循环的编排细节执行多轮工具循环时有几个细节会影响成败值得单独说。一是tool_choice参数。默认值是auto让模型自己判断是否需要调用工具。但有些场景我们希望模型必须调用某个工具比如用户说读一下这个Excel模型却觉得不读也能猜个大概直接返回文本这就是不听话。这时候可以把tool_choice设置成{type: function, function: {name: read_excel_data}}强制模型必须调用这个工具。当然这个开关要谨慎用不是所有场景都适合强制。二是上下文累积问题。每次循环都把带tool_calls的消息和带tool结果的消息追加到messages列表里这个列表只会越来越长。工具特别多、任务链条特别长的时候很容易顶到模型的上下文上限。我的经验是最终用户能看到的回复不需要把中间好几轮的JSON调试日志全塞进去。可以在第二次循环时把前期工具执行细节压缩成一行摘要或者直接把无用的中间轮次从上下文里裁掉。这个优化后面工具响应过长的那一节还会再展开。三是一个工具执行失败时的处理策略。比如Excel文件根本不存在工具服务返回HTTP 400。程序不应该直接把异常原样丢给模型——模型读到一堆Python traceback根本不知道怎么办。更好的做法是捕获异常转换成一句人话文件不存在请检查路径是否正确。把它作为roletool的内容回传给模型。这样模型才能基于这句清晰的结果向用户说出可执行的下一步指令比如请确认文件名后重新发起请求。4. 从单工具到工具箱的工程化改造4.1 工具注册、描述与版本管理做到这步聊天机器人确实能干活了但它身上挂的工具也就一两个跟标题里tool server这个称呼还有差距。真正的tool server是要承载几十上百个不同能力、不同团队贡献工具的。这时候光写函数就不够了得考虑工程化。我做的第一件事是统一工具注册方式。不搞一堆散落的if-elif判断而是把所有工具函数集中注册到一个字典里注册时带上名称、描述、执行函数、参数Schema四件套REGISTRY { read_excel_data: { description: ..., params_schema: {...}, handler: read_excel_data, }, send_dingtalk_message: { description: ..., params_schema: {...}, handler: send_dingtalk_message, }, }execute_tool函数拿到名称去REGISTRY里查查到就执行查不到就返回未知工具。这么做的好处是加新工具只需要往注册表里加一条记录不用动调用逻辑。而且注册表的description和params_schema字段可以直接用来生成发给LLM的tools参数一套配置两处用避免维护两套不一致的描述。工具描述还有一个保鲜问题。我见过不少人改完函数逻辑忘了更新描述结果模型还按老描述去猜参数。这个真没法靠自觉只能靠流程卡每次改造工具函数必须同步更新描述和Schema否则CI里挂一个校验脚本直接拦截合并请求。4.2 鉴权、限流和审计一个都不能少tool server一旦独立部署就要直面安全问题因为它的调用方虽然是LLM编排层但HTTP接口本身是暴露在公网或内网上的。至少三个点得补上。第一是鉴权。一开始我用最简单的方案——在请求Header里带一个Bearer Tokenserver这边校验。工具多了之后可以换更细的权限模型比如每个工具标记可见范围某些敏感工具只允许特定来源调用。第二是严谨的参数校验。模型的手是不可信的它对真实世界的约束没有感知所以工具函数的参数校验不能省。还是那个路径问题read_excel_data的file_path如果传成/etc/passwd这种绝对路径而服务的pandas正好能读那就是灾难。所以必须做一个路径白名单校验把路径先规范化再用os.path.commonpath确认它落在允许的根目录里。第三是审计日志。谁在什么时候调用了哪个工具、传了什么参数、返回了什么结果全部打日志。这不是为了监控哪个人而是为了出事之后能查——毕竟工具是给模型用的模型出错时总得有个回溯的线索。5. 实操中踩过的坑和排查思路5.1 模型不调用工具反而一本正经编数据这是我最开始遇到、也最让人头大的问题明明把工具声明都传进去了用户问昨天销售额是多少模型不去调读取工具反而直接回一句昨天的销售额是120万环比增长8%。编得还挺像那么回事但只要对一下真实数据就知道是错的。我排查了很久最后发现原因基本集中在三个地方。第一个原因是工具描述写得模糊模型不知道它有这个工具可用或者误以为这个工具跟当前问题无关。解决办法是把描述改得更贴脸比如加上当用户询问统计数据时必须使用此工具不要直接凭记忆回答这类指令性描述。第二个原因是tool_choice设成了空或没传有些模型在可选可不选的时候会偷懒直接用文本回。第三个原因是模型本身能力不够小参数量模型在Function Calling上的表现比大模型差很多这时候只能换模型或者把问题拆更细。总之遇到模型编数据先别急着骂模型按这三步排查基本能定位。5.2 arguments解析失败JSON字符串坑死人模型返回的tool_calls里arguments是一个字符串必须自己json.loads。但这个字符串有时候不是标准JSON——比如模型在参数值里多写了一个末尾逗号或者把单引号当成了JSON的合法引号又或者返回的JSON对象里有裸的True/False而不是true/false。这些情况不常见但确实会发生一旦发生程序就会抛异常。我的处理策略是三层兜底。第一层先用正则把明显的非法字符修掉比如把单引号替换成双引号把True/False替换成true/false去掉尾随逗号。第二层正常json.loads万一还失败就尝试用ast.literal_eval这个Python自带的解析器——它宽容度高一些能处理一些非标准写法。第三层实在解析不出来就把解析错误作为工具结果回传给模型告诉它参数解析失败请重新生成合法的JSON参数。实测下来第三层兜底虽然多消耗一轮对话但至少能把流程走完不会直接卡死。5.3 工具输出太长把上下文窗口塞满了Excel读取这个工具一个几十行的DataFrame转成Markdown表格再塞进上下文token一下就上去了。如果用户要求读取全部数据而且这个Excel正好有几千行那工具结果直接能把上下文窗口打爆API直接报错。这个坑几乎所有做数据处理类工具的都会踩。我最终采用的方案是三层裁剪。第一层工具侧强制限制row_limit最大值我在Schema里把maximum设成200防止模型传一个过大的数字。第二层返回给模型前对内容做压缩比如超过一定行数就只保留前30行加一个已省略剩余N行的提示让模型知道数据不止这些但不让它看到全部。第三层如果任务明确只需要输出一个统计指标我干脆让工具直接返回聚合结果比如平均值、总和、Top3这些而不是原始明细。思路很简单模型不需要看到所有数据它只需要看到足够回答用户问题的那个答案片段。5.4 并发、超时与错误处理最后一个坑来自运维层面。机器人是群聊服务群里几个人同时发不同指令机器人框架会把消息同时塞进来。如果我的tool server是同步阻塞的一个慢工具比如钉钉接口卡了10秒就会阻塞后面所有请求。尤其是当时好几个同事同时让机器人查销售数据整个服务的延迟肉眼可见地涨了上去。解决思路分两步。第一步FastAPI侧把工具函数的定义从普通def改成async def并用await去等待耗时操作这样IO等待期间服务器能腾出手去处理其他请求。第二步给机器人编排循环加上并发隔离每个用户会话的上下文是独立的工具调用互不影响同时给整个工具执行加一个总的超时比如30秒。为什么是30秒因为正常一次工具调用加上模型两次往返30秒绰绰有余超过就是链路出问题了宁可报错也不该让用户无限等下去。附一个我踩坑时总结的速查表现象常见原因处理方式模型不调用工具直接编答案工具描述不清晰 / tool_choice未设置 / 模型能力不足优化描述、按场景强制tool_choice、换更强模型工具参数解析报错模型返回非标准JSON字符串正则预处理、ast.literal_eval兜底、失败回传让模型重试工具结果截断/超限返回数据量超出上下文窗口限制row_limit、压缩结果、优先返回聚合指标多人同时使用机器人时卡顿工具执行阻塞了事件循环改用async函数、设置总超时、会话隔离文件路径越界/非法访问参数未做白名单校验路径规范化 commonpath白名单校验这套流程跑通之后我最大的感受是聊天机器人这个形态本身并不重要重要的是它背后那套能被LLM按需调用的工具网络。从那个聊天机器人到那个tool server本质上是把一个会说话的壳变成了一个能把想法变成动作的执行骨架。最后再分享一个小技巧工具描述文档别一口气写完先写一半上线跑几天看看模型实际调用时的参数日志哪里猜错了、哪里传错了再迭代补全描述。让模型用日志替你审稿比你自己闭门造车写出来的Schema准确得多。
阅读完成 · 觉得有帮助?
咨询建站