为什么要搞工具调用 大语言模型再聪明,也有搞不定的事——算术不精确、没法查实时数据、不能操作外部系统。解决方案就是给模型配上”工具”,让它在需要的时候调用外部函数来补齐能力。
这就是 Function Calling(函数调用),或者更通用的叫法:Tool Use(工具使用)。
OpenAI 在 GPT-3.5/4 里率先把这个能力产品化了,搞了一套 JSON 格式的函数定义和调用协议。但在开源世界里,不同模型对工具调用的实现方式差异很大。Hermes 走的是 XML 标签 这条路线,如果你之前只接触过 OpenAI 的 JSON 方案,刚上手可能会有点不习惯。
这篇文章会把 Hermes 的工具调用机制从头到尾拆给你看。
Hermes 工具调用的核心机制 Hermes 使用一组特定的 XML 标签来实现工具调用的完整生命周期:
<tools> — 定义可用工具的列表
<tool_call> — 模型发出的调用请求
<tool_response> — 外部系统返回的结果
整个流程就是:你在 system prompt 里用 <tools> 告诉模型有哪些工具可以用,模型在对话中判断什么时候该调用工具,输出 <tool_call> 标签,你的代码解析这个标签、执行对应函数、把结果包在 <tool_response> 里喂回去,模型拿到结果后继续生成回复。
比起 OpenAI 的纯 JSON 方案,这种 XML 方式有一个好处:标签的开闭结构天然适合流式解析 。你不用等整个 JSON 对象生成完才能判断模型是否发起了工具调用,看到 <tool_call> 开标签就能立刻准备了。
工具定义的详细格式 工具定义放在 system prompt 的 <tools> 标签内,每个工具用 JSON 格式描述(没错,外层是 XML,里面是 JSON,有点混搭):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 <tools > [ { "name": "get_weather", "description": "获取指定城市的当前天气信息", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称,如北京、上海" }, "unit": { "type": "string", "enum": ["celsius", "fahrenheit"], "description": "温度单位,默认celsius" } }, "required": ["city"] } }, { "name": "search_documents", "description": "在知识库中搜索相关文档", "parameters": { "type": "object", "properties": { "query": { "type": "string", "description": "搜索关键词" }, "top_k": { "type": "integer", "description": "返回结果数量", "default": 5 } }, "required": ["query"] } } ] </tools >
几个关键点:
parameters 结构 基本遵循 JSON Schema 规范,和 OpenAI 的 function calling 参数定义兼容
description 要写好 — 模型就是根据 description 来判断什么场景该用什么工具的,描述越精确,调用越准确
required 字段 明确标出必填参数,减少模型遗漏参数的概率
当模型判断需要调用工具时,会在生成内容中插入 <tool_call> 标签:
1 2 3 <tool_call > {"name": "get_weather", "arguments": {"city": "北京", "unit": "celsius"}} </tool_call >
注意几件事:
name 对应 tools 里定义的函数名
arguments 里的参数要符合之前定义的 schema
模型可能在一次回复里发起多个 tool_call(并行调用)
多工具调用的情况长这样:
1 2 3 4 5 6 7 8 9 我来帮你查一下这两个城市的天气。 <tool_call > {"name": "get_weather", "arguments": {"city": "北京"}} </tool_call > <tool_call > {"name": "get_weather", "arguments": {"city": "上海"}} </tool_call >
模型可能在 tool_call 前面加一段自然语言说明,也可能直接输出标签。你的解析代码要能处理这两种情况。
你的后端拿到 tool_call 后,执行对应的函数,把结果用 <tool_response> 包起来放到下一轮对话里:
1 2 3 <tool_response > {"city": "北京", "temperature": 22, "unit": "celsius", "condition": "晴", "humidity": 45} </tool_response >
模型收到 tool_response 后,会基于这些数据生成最终的自然语言回复,比如告诉用户北京现在 22 度、晴天、湿度 45%。
用 Python 实现完整的工具调用循环 下面这段代码展示了一个完整的工具调用循环,基于 llama-cpp-python(如果你用 Ollama 部署 Hermes ,思路一样,只是调用接口不同):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 import jsonimport refrom llama_cpp import Llamallm = Llama( model_path="./Hermes-3-Llama-3.1-8B-Q5_K_M.gguf" , n_ctx=8192 , n_gpu_layers=-1 , chat_format="chatml" ) tools_definition = """ <tools> [ { "name": "calculate", "description": "执行数学计算,支持四则运算和常见数学函数", "parameters": { "type": "object", "properties": { "expression": { "type": "string", "description": "数学表达式,如 2+3*4 或 sqrt(16)" } }, "required": ["expression"] } }, { "name": "get_stock_price", "description": "获取指定股票的当前价格", "parameters": { "type": "object", "properties": { "symbol": { "type": "string", "description": "股票代码,如 AAPL、TSLA" } }, "required": ["symbol"] } } ] </tools> """ def execute_tool (name: str , arguments: dict ) -> str : """实际的工具实现""" if name == "calculate" : try : import math allowed = {k: getattr (math, k) for k in dir (math) if not k.startswith("_" )} result = eval (arguments["expression" ], {"__builtins__" : {}}, allowed) return json.dumps({"result" : result}) except Exception as e: return json.dumps({"error" : str (e)}) elif name == "get_stock_price" : mock_prices = {"AAPL" : 189.50 , "TSLA" : 245.30 , "GOOGL" : 175.20 } symbol = arguments["symbol" ] price = mock_prices.get(symbol) if price: return json.dumps({"symbol" : symbol, "price" : price, "currency" : "USD" }) return json.dumps({"error" : f"未找到股票 {symbol} " }) return json.dumps({"error" : f"未知工具: {name} " }) def parse_tool_calls (text: str ) -> list : """从模型输出中提取所有 tool_call""" pattern = r"<tool_call>\s*(.*?)\s*</tool_call>" matches = re.findall(pattern, text, re.DOTALL) calls = [] for match in matches: try : call = json.loads(match ) calls.append(call) except json.JSONDecodeError: continue return calls def chat_with_tools (user_message: str ): system_prompt = f"""You are a helpful assistant with access to the following tools: {tools_definition} When you need to use a tool, output a <tool_call> tag with the function name and arguments. After receiving a <tool_response>, use the information to answer the user. Always respond in Chinese.""" messages = [ {"role" : "system" , "content" : system_prompt}, {"role" : "user" , "content" : user_message} ] response = llm.create_chat_completion(messages=messages, max_tokens=1024 ) assistant_msg = response["choices" ][0 ]["message" ]["content" ] tool_calls = parse_tool_calls(assistant_msg) if not tool_calls: return assistant_msg messages.append({"role" : "assistant" , "content" : assistant_msg}) for call in tool_calls: result = execute_tool(call["name" ], call.get("arguments" , {})) tool_response = f"<tool_response>\n{result} \n</tool_response>" messages.append({"role" : "user" , "content" : tool_response}) final_response = llm.create_chat_completion(messages=messages, max_tokens=1024 ) return final_response["choices" ][0 ]["message" ]["content" ] print (chat_with_tools("帮我算一下 (15 * 23) + sqrt(144) 等于多少" ))print (chat_with_tools("AAPL 现在什么价格?" ))
核心思路就是一个循环:生成 → 检测工具调用 → 执行 → 喂回结果 → 再生成 。实际生产环境中可能需要多轮工具调用(模型拿到一个结果后又发起另一个调用),把这个循环跑多次就行。
和 OpenAI Function Calling 的差异对比 如果你之前用过 OpenAI 的方案,这里做一个直观对比:
维度
OpenAI 方案
Hermes XML 方案
工具定义位置
API 参数中的 tools 字段
System Prompt 的 <tools> 标签
定义格式
JSON
XML 包裹 JSON
调用格式
API 响应的 tool_calls 字段
文本中的 <tool_call> 标签
结果回传
消息角色 tool
<tool_response> 标签
并行调用
原生支持
多个 <tool_call> 标签
流式解析
需要拼接 JSON delta
检测 XML 开闭标签即可
强制调用
tool_choice: required
在 prompt 里指示
一个比较大的实际差异是:OpenAI 的方案在 API 层做了结构化处理,tool_calls 是独立字段,不会和普通文本混在一起。Hermes 的方案里,tool_call 标签是混在模型输出文本中的,需要自己做解析和分离。
好处是灵活——模型可以在调用工具的同时输出解释性文字(”让我查一下” + tool_call),用户体验更自然。麻烦是你的代码必须处理各种边界情况,比如标签不完整、JSON 格式错误这类问题。
提升工具调用准确率的技巧 实际使用中,Hermes 的工具调用不是百分百稳定的,尤其是小参数量版本(8B)。下面几个方法实测有效:
1. 工具描述要具体,别写模糊表述
1 2 3 4 5 { "name" : "search" , "description" : "搜索东西" } { "name" : "search_product" , "description" : "在商品数据库中按关键词搜索商品,返回商品名称、价格和库存" }
2. 给参数加 example 信息
虽然 JSON Schema 标准里没有 example 字段,但 Hermes 能理解它:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 { "name" : "send_email" , "description" : "发送电子邮件" , "parameters" : { "type" : "object" , "properties" : { "to" : { "type" : "string" , "description" : "收件人邮箱地址,例如 user@example.com" } , "subject" : { "type" : "string" , "description" : "邮件主题" } , "body" : { "type" : "string" , "description" : "邮件正文,支持纯文本" } } , "required" : [ "to" , "subject" , "body" ] } }
3. 在 System Prompt 里加调用规范
在 tools 定义后面追加一段指令,明确告诉模型怎么输出调用格式:
1 2 3 4 5 6 7 8 9 当你需要使用工具时,请严格按照以下格式输出: <tool_call> {"name": "工具名", "arguments": {"参数名": "参数值"}} </tool_call> 注意: - arguments 必须是合法的 JSON 对象 - 参数名和类型必须和工具定义一致 - 如果不需要调用工具,直接回复即可,不要输出空的 tool_call
4. 用 few-shot 示例引导
在 system prompt 或前几轮对话中放一个正确调用示例,让模型跟着格式走:
1 2 3 4 5 6 示例对话: 用户:今天上海天气怎么样? 助手:我来查一下上海的天气。 <tool_call> {"name": "get_weather", "arguments": {"city": "上海"}} </tool_call>
错误处理和防御性编程 生产环境里,工具调用会遇到各种异常。你需要有一套健壮的错误处理机制:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 def robust_tool_execution (call: dict ) -> str : """带错误处理的工具执行""" name = call.get("name" , "" ) arguments = call.get("arguments" , {}) available_tools = {"calculate" , "get_stock_price" , "get_weather" } if name not in available_tools: return json.dumps({ "error" : f"工具 {name} 不存在" , "available_tools" : list (available_tools) }) try : if not isinstance (arguments, dict ): arguments = json.loads(arguments) if isinstance (arguments, str ) else {} except json.JSONDecodeError: return json.dumps({"error" : "参数格式错误,无法解析为 JSON" }) try : result = execute_tool(name, arguments) return result except TimeoutError: return json.dumps({"error" : f"工具 {name} 执行超时" }) except Exception as e: return json.dumps({"error" : f"工具执行异常: {str (e)} " })
让错误信息以结构化方式返回给模型,模型通常能理解并给用户一个合理的回复(比如”查询失败,请稍后重试”),而不是编造数据。
构建多轮 Agent Loop 工具调用最终是为了构建 Agent——一个能自主决策、多步执行的智能体。关于 Hermes 模型的基本介绍 在之前的文章里已经讲过了,这里关注怎么基于工具调用搭 Agent 循环:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 def agent_loop (user_message: str , max_iterations: int = 5 ): """支持多轮工具调用的 Agent 循环""" messages = [ {"role" : "system" , "content" : system_prompt_with_tools}, {"role" : "user" , "content" : user_message} ] for i in range (max_iterations): response = llm.create_chat_completion( messages=messages, max_tokens=2048 ) assistant_msg = response["choices" ][0 ]["message" ]["content" ] messages.append({"role" : "assistant" , "content" : assistant_msg}) tool_calls = parse_tool_calls(assistant_msg) if not tool_calls: return assistant_msg for call in tool_calls: result = robust_tool_execution(call) messages.append({ "role" : "user" , "content" : f"<tool_response>\n{result} \n</tool_response>" }) return "达到最大迭代次数,Agent 循环终止。"
max_iterations 是安全阀,防止模型陷入无限调用循环。实际场景中 3-5 次迭代能覆盖大部分需求。
如果你想搭更完善的 Agent 系统,可以去 cocoloop 社区看看其他人分享的生产级实现方案,有不少基于 Hermes 的 Agent 框架值得参考。
收尾 Hermes 的 XML 工具调用方案和 OpenAI 的 JSON 方案表面上差别挺大,但底层逻辑相通——定义工具、检测调用、执行函数、回传结果。掌握了这套机制,就能让 Hermes 从一个”只会说话”的模型变成”能做事”的 Agent。
几个核心要点回顾:
用 <tools> 标签在 system prompt 里定义工具,参数用 JSON Schema 描述
解析 <tool_call> 提取函数名和参数,执行后用 <tool_response> 回传
工具描述写得好坏直接影响调用准确率
做好错误处理,别让一个工具失败拖垮整个对话
多轮调用循环是构建 Agent 的基础
下一步可以看看 Hermes JSON Mode ,了解怎么让模型稳定输出结构化数据——这和工具调用是一对好搭档。
延伸阅读:OpenClaw 社区资源 本文由 CocoLoop 中文社区出品。如果你在研究 AI Agent 与主流模型的工程化落地,姊妹站 OpenClaw 中文社区 也许会有帮助: