☰
Function Calling与AI Agent开发:从工具定义到工程化落地的完整指南
2026/10/20 2:55:58 网站建设 项目流程

做AI Agent开发,绕不开Function Calling。我习惯把它比作Agent的“手和脚”:大脑负责推理,手脚负责执行,而把大脑和手脚连接起来的控制回路,就是Harness Engineering。这篇博文不整理论,直接从机制讲起,带你从工具定义、调用循环、生产环境踩坑到工程体系,完整走一遍Function Calling这条链路。适合已经跑通基础LLM调用、想深入搞Agent落地的开发者,也适合刚接触Agent、想搞清楚“模型到底是怎么调用外部能力”的新手。

Function Calling的本质,是让大模型输出一段结构化指令,而不是让模型自己动手干。真正执行指令的是你自己的代码,模型只负责“决定该调什么、参数怎么填”。理解了这句话,后面所有工程手段都会顺理成章。这篇内容里出现的代码,都是我在模拟项目X里实际跑过的简化版本,你拿到手改改工具列表就能用。

1. Function Calling 的本质:从“参谋”到“执行者”

1.1 它只是一条“结构化指令通道”

先放下所有工程细节,回到最基础的一次交互。普通对话里,用户问“某城市天气怎么样”,模型会直接回答一段文字。但在Function Calling模式下,模型看到你预先声明了一个工具get_current_weather(city),它就会在回答之前先输出类似这样的结构化指令:

{ "name": "get_current_weather", "arguments": "{\"city\": \"某城市\"}" }

注意,模型并没有真的去查天气,它只是产生了一个“调用意图”。你的代码拿到这段JSON,自己去调用天气API,再把API返回的结果拼进对话上下文,交回给模型,让模型基于真实数据生成最终回答。

这个机制的价值,是把模型的“语言能力”和外部世界的“数据/动作能力”解耦。模型不需要具备调用API的技术细节,也不需要真的懂SQL、懂HTTP请求,它只需要知道“有个工具能干什么、需要我提供什么参数”。所有脏活累活,由你写的外部代码完成。

这套设计解决了一个核心问题:大模型本身没有长期记忆、没有实时数据、也没有操作外部系统的能力。但通过Function Calling,模型可以把这些能力“外包”给外部代码,从而实现从“只会说”到“既会说又会做”的跨越。在我参与的某跨平台系统开发里,第一步就是把内部十几个API全部封装成工具定义,效果立竿见影。

1.2 为什么它是AI Agent的“手和脚”

做个比喻。把LLM当成一个人的大脑,它负责理解、推理、拆解问题。但光有大脑不够,人要完成实际任务,需要手去拿东西、脚去走路。对大模型来说,Function Calling就是那双手和脚——“手”负责调用系统工具去操作外部资源,“脚”负责在不同服务之间移动数据、发起下一步动作。

没有Function Calling的AI Agent,基本上是个“嘴强王者”。你说“帮我查一下明天的安排”,它只能回你“好的,你可以打开日历看看”,因为它的训练数据里根本没有你的日历。而加上Function Calling之后,它能直接调用日程查询工具,返回“你明天上午10点有一个项目评审”,整个体验就完全不一样了。

从这个角度看,Agent能力的上限,很大程度上取决于你给它配了多少双“好用的手脚”。模型再聪明,如果工具定义写得稀烂,它也会频繁用错参数、选错工具。反过来,工具定义写得精准,模型的判断力和执行力就能被充分放大。

1.3 术语澄清:Function Calling / Tool Calling / Tool Use

这三个词在不同的模型平台文档里反复出现,实际指的都是同一件事,只是侧重点略有不同。Function Calling更强调“函数签名”和结构化参数,Tool Calling更强调“工具”这个抽象概念,而Tool Use通常是教材里最通用的说法。你只需要知道:它们都是“让模型输出工具调用意图”的机制,不用被术语绕晕。

还有一个容易混淆的概念是JSON Mode。有的平台提供了一个强制模型输出合法JSON的功能,但它和Function Calling不是一回事。JSON Mode只是约束输出格式,模型并不会感知“有哪些工具可用”;Tool Calling才是完整的工具认知、参数生成、结果回传闭环。实际项目里我遇到不少人试图用JSON Mode去模拟工具调用,结果就是模型要么乱猜功能名,要么在参数里胡编值,最后还是要老老实实改回Function Calling。

2. 给模型写好“一双手”:工具定义实战

2.1 工具定义的本质:一份让模型读懂的函数说明书

每个工具定义,本质上就是一份JSON Schema格式的“函数说明书”。它告诉模型三件事:这个工具叫什么名字、是干什么的、需要哪些参数。模型完全依赖这份说明书的描述质量来决定“要不要调用、怎么填参数”,所以说明书写得不好,后面什么都白搭。

一个标准的工具定义长这样:

{ "type": "function", "function": { "name": "get_current_weather", "description": "查询指定城市当天天气。当用户提到城市名并询问温度、风力、降水情况时调用;若用户未指明城市,不要调用。", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称,必须是用户消息中出现过的城市。" } }, "required": ["city"] } } }

三个核心字段各有讲究。name要短而语义清晰,最好动词开头,比如query_schedule、create_reminder,避免用tool1这种没意义的名字。description写的是“功能边界+触发条件”,不光是说这个工具能干什么,还要说明它不能干什么、什么情况下才应该触发。parameters则是对参数的类型和取值范围做硬约束,尽量把自由度缩到最小,模型填错的概率才会变小。

2.2 description 是模型的“使用说明书”

我见过太多工具定义翻车,根源都在一句话不够准确。举个例子,同样是“查询天气”的工具,有人写“查天气”,有人写“查询指定城市当天天气。当用户提到城市名并询问温度、风力、降水情况时调用;若用户未指明城市,不要调用”。这两个描述在真实调用中的命中率差距是巨大的。

为什么?因为模型在决定是否调用工具时,靠的是语义匹配。它看到“查天气”三个字,只能判断“用户在聊天气”,但搞不清楚“用户说‘明天适合出门吗’要不要调用”,于是就可能在该调的时候不调、不该调的时候乱调。而你一旦在description里写清楚触发条件和边界条件,模型的决策就稳定得多。

我在某图像处理Demo里调试过一个定时提醒工具,最初description写的是“创建提醒”,结果模型经常把用户随口一句“你帮我记住这个”也识别成提醒请求。后来我改成“创建定时提醒。仅当用户明确给出提醒文字和提醒时间时调用,否则先向用户询问时间”,误调率直接降了一大截。这个经验非常实用:description里主动加上“若XX,则不要调用”,会让模型的判断准确很多。

2.3 参数约束:能枚举就绝不放飞

参数的约束强度,直接决定你后续要做多少脏活。尽量把每个参数的type写准确,能用enum限制可选值就用enum,能设default就设default,能标注“必填”就标注required。

例如,一个创建日程工具的时间参数,最怕模型传一个“明天下午三点”这种中文字符串,导致你代码里datetime.strptime直接报错。更好的做法是告诉模型“该参数必须是ISO 8601格式,例如2025-01-01T15:00:00”。哪怕这个要求很啰嗦,也一定要写进description里,因为模型不会默认知道你的时间格式偏好。

再比如工单系统的优先级字段,你可以在schema里加上enum: ["低", "中", "高"],这样模型就只能在三个值里选,不会自己发明一个“紧急到爆炸”传给你。这类硬约束就是给模型的“填空题”加限制条件,限制得越清楚,后端的解析和校验工作就越轻松。

2.4 一套可直接复用的工具定义模板

我沉淀了一个三个工具的小模板,覆盖了最常见的“查数据、写数据、发通知”三类场景,你复制改改就能用:

[ { "type": "function", "function": { "name": "query_schedule", "description": "查询指定日期的日程安排。用户询问某天安排、空闲时间、会议时间时调用。日期缺省时默认当天。", "parameters": { "type": "object", "properties": { "date": { "type": "string", "description": "日期,格式YYYY-MM-DD,例如2025-01-01" } }, "required": ["date"] } } }, { "type": "function", "function": { "name": "create_reminder", "description": "创建提醒。仅当用户明确给出提醒内容和提醒时间时调用;时间缺省时礼貌询问。", "parameters": { "type": "object", "properties": { "text": { "type": "string", "description": "提醒内容" }, "time": { "type": "string", "description": "提醒时间,ISO 8601格式" } }, "required": ["text", "time"] } } }, { "type": "function", "function": { "name": "send_message", "description": "向联系人发送一条即时消息。仅当用户明确要求发送消息时调用。", "parameters": { "type": "object", "properties": { "contact": { "type": "string", "description": "联系人名称" }, "content": { "type": "string", "description": "消息内容" } }, "required": ["contact", "content"] } } } ]

这套模板我用在了好几个模拟项目里,结论是:工具数量在五六个以内时,模型的选择准确率最高;超过十个,就要开始考虑第6章说的路由策略了。

3. Agent Loop:工具调用的核心回路

3.1 完整调用链:一次天气查询的全过程

工具定义好了,接下来最关键的就是Agent Loop——循环调用模型、执行工具、把结果回传的回路。不要急着写代码,先把一次完整的数据流动理解透。

以“某城市明天天气怎么样”为例,完整流程分五步:

  1. 把用户消息和工具定义一起发给模型。模型返回Assistant消息,里面只有一个tool_calls字段,指向get_current_weather。
  2. 你的代码收到tool_calls后,解析出工具名和参数。注意,arguments是一个JSON字符串,要json.loads之后再传给执行函数。
  3. 调用真实的天气API,得到一个形如{"temperature": 18, "condition": "阴"}的结果。
  4. 把这个结果封装成一条role: "tool"的消息,里面必须带上对应tool_call_id,表示这是执行哪一次调用的结果。
  5. 把包含tool结果的消息再次发给模型,模型结合结果生成最终自然语言回答:“明天某城市阴,18度。”

这五步就是整个Function Calling的基本盘。很多人第一次调通之后会觉得“不过如此”,但到了多轮调用、并行调用、流式场景,问题才会慢慢浮出来。

3.2 多轮循环与终止条件

现实中的任务往往需要连续调用多个工具。比如“帮我查一下明天几点开会,然后设个提前半小时的提醒”,模型第一步要query_schedule,拿到会议时间后,第二步再create_reminder。这不可能是模型一次性完成的,必须靠循环推进。

循环的逻辑不复杂:每次拿到模型的回复,先检查有没有tool_calls,有就执行工具并把结果以role: "tool"追加到messages,然后带着这条新消息再调一次模型。直到某次回复里不再包含tool_calls,循环就结束了,这时模型的content字段里就是最终回答。

但循环必须加终止条件。最常用的是max_steps(例如5轮),达到上限就强制退出并返回中间结果或报错。原因很简单:模型有可能一直调用工具停不下来,尤其是当工具定义存在混淆、或者用户表达含糊的时候。没有终止条件的Agent Loop,生产环境上跑几个小时都不奇怪。我见过一次真实事故,一个任务里模型连续调了二十多次同一个查询工具,最后把上下文和费用都跑爆了,就是从没设max_steps开始的。

messages = [{"role": "user", "content": user_query}] for step in range(max_steps): resp = client.chat.completions.create( model=MODEL_NAME, messages=messages, tools=TOOL_SCHEMAS, ) msg = resp.choices[0].message messages.append(msg) if not msg.tool_calls: return msg.content tool_count = {} for tc in msg.tool_calls: fn_name = tc.function.name tool_count[fn_name] = tool_count.get(fn_name, 0) + 1 if tool_count[fn_name] > 3: raise RuntimeError(f"循环检测:{fn_name} 被重复调用") arguments = json.loads(tc.function.arguments) result = execute_tool(fn_name, arguments) messages.append({ "role": "tool", "tool_call_id": tc.id, "content": json.dumps(result, ensure_ascii=False) }) raise RuntimeError("超过最大调用轮次")

这段代码里我额外放了一个重复工具检测,后面第5章会展开讲。

3.3 并行调用:模型一次拿回多个tool_calls

很多模型一次回复里可以带多个tool_calls,也就是“并行调用”。比如用户问“某城市今天天气怎么样?顺便提醒我下午3点喝水”,模型可能同时返回get_current_weather和create_reminder两个调用,你的代码需要把它们全部执行后再统一回传。

这里有一个非常容易踩的坑:两个工具的执行结果必须分别对应各自的tool_call_id,消息顺序不能乱。模型是根据tool_call_id来对齐结果的,如果你把A工具的结果挂到B工具的id上,模型会认为B工具返回了A的数据,后续推理就会错乱。

并行调用确实能减少交互轮数、提升响应速度,但要注意“并行”是有条件的:多个工具之间不能有依赖关系。比如“先去查日期再创建日程”,后一个工具依赖前一个的结果,就不能并行。而“查天气+发消息”这种互不干扰的,并行执行就很合适。实操中,我会根据具体任务把工具按期分组,同时在日志里记录每次并发执行的实际耗时,这样才能判断并行到底帮我们省了多少时间。

3.4 tool_choice 参数怎么选

大多数模型API都提供了一个控制工具调用行为的参数,叫tool_choice,常见取值有auto、required、none,以及指定某个具体工具名。含义很直白:auto是让模型自己决定调不调,required是强制至少调用一次工具,none是直接禁用工具调用,指定工具名则是强制执行某一个工具。

我自己的经验是:默认场景用auto就好,最自然。required适合意图分派场景,比如你已经确定用户这句话一定对应某个动作,不想让模型说废话,直接让它出工具调用。指定工具名则适合那些用户输入高度固定的流程,比如“给我把这条消息发给某人”,你确定唯一该用的工具就是send_message,强制指定可以避免模型偶尔选错。

tool_choice 取值行为典型场景
auto模型自主决定是否调用大多数开放对话
required强制至少调用一次工具意图分派、任务路由
none禁止调用任何工具闲聊、纯文本生成
指定工具名强制使用某个工具高度固定的单步流程

这个参数虽然不起眼,但用对了,能让Function Calling的错误率显著下降。我在模拟项目X里对比过,把“发通知”这一步的tool_choice从auto改成指定的send_message之后,误调率直接降为零。

4. 端到端实战:写一个带工具的“办公助手”

4.1 场景设计与工具清单

理论讲再多,不如直接上一段能跑的示例。我这里做一个“办公助手”,场景很简单:用户用自然语言查询日程、创建提醒、发送消息。工具清单就是第2章那三个:query_schedule、create_reminder、send_message。

为什么选这三个工具做演示?因为它们覆盖了最典型的三种工具形态:查询只读数据、写入新数据、触发外部动作。你以后写商业项目时,90%的工具基本都能归到这三类里。同时它们的参数也足够简单,方便把整个Agent Loop看得清清楚楚。

模拟整机环境上,我直接用一套本地mock的API替代真实服务:query_schedule里维护一个内存日程表,create_reminder直接写入列表,send_message打印一条日志表示“已发送”。这不影响理解,因为工具执行的具体实现永远都是你自己的业务代码,和Function Calling的通用回路完全解耦。

4.2 Agent Loop 代码实现

下面是完整的示例代码。我没有引入额外框架,只用了标准库和一个通用的HTTP请求封装,方便你直接移植到自己的项目里。

import json MODEL_NAME = "your-model-name" client = get_llm_client() # 各家平台的客户端初始化,自行替换 TOOL_SCHEMAS = [...] # 就是前面第2.4节那三个工具定义 TOOL_REGISTRY = {} def register_tool(name): def decorator(fn): TOOL_REGISTRY[name] = fn return fn return decorator @register_tool("query_schedule") def query_schedule(date: str) -> dict: schedule_db = { "2025-01-01": [ {"time": "10:00", "title": "项目评审"}, {"time": "15:00", "title": "外部对接会"} ] } return {"events": schedule_db.get(date, [])} @register_tool("create_reminder") def create_reminder(text: str, time: str) -> dict: reminder_list.append({"text": text, "time": time}) return {"status": "created", "text": text, "time": time} @register_tool("send_message") def send_message(contact: str, content: str) -> dict: print(f"[发送消息] 收件人={contact}, 内容={content}") return {"status": "sent", "contact": contact} reminder_list = [] def execute_tool(name: str, arguments: dict) -> dict: fn = TOOL_REGISTRY.get(name) if fn is None: return {"error": f"未知工具: {name}"} try: return fn(**arguments) except TypeError as e: return {"error": f"参数错误: {str(e)}"} def run_agent(user_query: str, max_steps: int = 5) -> str: messages = [{"role": "user", "content": user_query}] for step in range(max_steps): resp = client.chat.completions.create( model=MODEL_NAME, messages=messages, tools=TOOL_SCHEMAS, ) msg = resp.choices[0].message messages.append(msg) print(f"[Step {step}] 模型回复: {msg.content or msg.tool_calls}") if not msg.tool_calls: return msg.content for tc in msg.tool_calls: fn_name = tc.function.name arguments = json.loads(tc.function.arguments) print(f"[Step {step}] 调用工具 {fn_name}, 参数={arguments}") result = execute_tool(fn_name, arguments) print(f"[Step {step}] 工具返回: {result}") messages.append({ "role": "tool", "tool_call_id": tc.id, "content": json.dumps(result, ensure_ascii=False) }) return "任务在指定轮次内未完成。" if __name__ == "__main__": print(run_agent("明天上午10点有会,帮我设一个提前半小时的提醒"))

代码里有一个值得注意的地方:我把每次模型回复、工具调用、工具返回都打印了出来。在开发阶段,这种“全量日志”极其有用,因为它能让你像看监控屏幕一样看到模型每一步的决策路径,是判断问题的最好抓手。

4.3 跑一轮真实场景,看Agent的决策过程

让我把真实运行日志模拟一遍,你就知道这个Assistant的动态过程了。用户输入是“明天上午10点有会,帮我设一个提前半小时的提醒”。

第一步,模型思考后发现自己有两个任务:查日程和设提醒。但这里它先用query_schedule确认“明天上午10点”的日程确实存在,然后才能决定提醒时间。于是第一轮返回了一个tool_calls:

[Step 0] 模型回复: tool_calls=[ {name: "query_schedule", arguments: {"date": "2025-01-01"}} ]

代码执行query_schedule,返回结果里有“10:00 项目评审”。messages里多了一条role: "tool"的消息。再次调用模型后,模型看到了日程结果,于是生成第二个tool_calls:

[Step 1] 模型回复: tool_calls=[ {name: "create_reminder", arguments: {"time": "2025-01-01T09:30:00", "text": "准备项目评审"}} ]

注意,模型的参数往往比你预想的更智能:它根据“提前半小时”自行算出了09:30。如果模型第一轮没有先查日程就盲目设提醒,那才是问题。日志里一旦看到这种自相矛盾的行为,你就要回去检查工具描述的边界写得够不够清楚。

最后执行提醒工具,返回成功后,模型生成最终回答:

好的,我已经查到你明天上午10点有项目评审,并帮你设置了提前半小时的提醒。

这样一个完整的Agent Loop就结束了。整个过程中,模型做了三次决策,你的代码执行了两个工具。日志记录了每一步,任何一步出了岔子,你都能立刻定位到是“模型选择错工具”还是“参数填得不对”。

5. 生产环境踩坑手册:这些坑我替你趟过了

5.1 模型“编造”参数的兜底策略

模型在调用工具时,偶尔会把参数填成编造的值,尤其是在用户表达模糊的情况下。最典型的是时间参数:用户说“明天下午”,模型可能直接传"2025-01-02T下午"这种非法格式,导致你json.loads之后还要再在业务代码里崩溃一次。

我的对策分三层。第一层,在工具description里明确写清楚格式要求,让模型“不得不”按格式来。第二层,在工具执行入口加一个参数校验器,比如校验时间字段能不能被解析成标准格式,不行就返回一个{"error": "参数格式错误,请重新生成正确的ISO 8601时间"},把这个错误消息作为role: "tool"结果回传给模型。第三层,实在校验不过,就重置一个“模型重新调用”的轮次,并附带一条系统提示“上一次工具调用参数非法,请改正后重试”。

这三层下来,绝大多数非法参数都能在内部消化掉,而不是一路炸到用户面前。

5.2 同一工具反复调用,死循环了

死循环在Function Calling里是最高发的故障之一。原因五花八门:用户问题太模糊、工具description有歧义、工具返回的结果格式不符合模型预期,于是模型一遍遍重试同一个工具。

我见过最夸张的一次,模型连续调用了同一个查询工具十几次,每次都传一样的参数,然后在日志里“原地打转”。排查到最后,是工具定义里把“查询航班”写得太泛,模型把用户一句“帮我看看这周末能去哪玩”也归类成航班查询,又拿不到结果,就陷入了重试循环。

生产级解法很简单:在Agent Loop里加一个“同工具重复调用检测”。可以是一个Counter,当同一个工具名在连续N轮里被调用超过阈值(比如3次),就直接中断循环,并返回一个“无法完成该任务,请明确问题或换个问法”的兜底答复。这个逻辑看起来粗暴,但在真实场景里非常救命。

5.3 工具返回结果太大,把上下文塞爆

工具执行完之后,原始结果会以role: "tool"消息的形式拼进messages,在下一次模型请求时一起发过去。如果某个工具返回了一个几千行的大列表,这段内容就会直接膨胀上下文,既增加费用,又让模型在长上下文里迷失重点。

我的处理办法是“结果预处理层”。在执行完工具后、把结果塞进messages之前,先做一步处理:保留关键字段、截断超长列表、必要时做摘要。比如一个查询全部门店库存的工具,原始返回可能有上千条记录,但Agent真正需要的可能只是“总库存不足300件的门店”。与其把全文塞给模型,不如在工具里就聚合好,直接返回一个精简报告,让模型拿着已经整理好的数据说话。

这个思路不仅省Token,还能显著提高回答质量,因为模型不用在噪音里翻找关键信息。

5.4 流式模式下 tool_calls 的分片拼装

我在第一个Function Calling项目里,直接用流式输出处理普通文本,完全没发现工具调用也走流式。结果真正跑起来才发现,流式模式下tool_calls不是一次性到位的,而是像打字机一样一段一段吐出来。

具体来说,模型会把tool_calls里的name和arguments拆成很多个分片,每个chunk里只有一小段增量。如果你不把这些增量拼接起来,json.loads就会直接失败。正确的做法是遍历所有chunk,把delta.tool_calls[0].function.name和delta.function.arguments累加到字符串里,最后再统一解析。

full_tool_calls = [] for chunk in resp: delta = chunk.choices[0].delta if not delta.tool_calls: continue for tc_delta in delta.tool_calls: index = tc_delta.index while len(full_tool_calls) <= index: full_tool_calls.append({"name": "", "arguments": ""}) if tc_delta.function: if tc_delta.function.name: full_tool_calls[index]["name"] += tc_delta.function.name if tc_delta.function.arguments: full_tool_calls[index]["arguments"] += tc_delta.function.arguments

这块代码建议直接存进你的工具库。我身边好几个开发者在流式Agent上线后遇到过同样的问题,都是在晚上加班debug时发现的。

5.5 工具太多,模型开始“选择困难”

当工具数量超过十来个之后,一个很尴尬的现象会出现:模型开始“选择困难”,频繁选错工具,或者因为工具too many而没有选择调用的工具。这其实不怪模型,工具列表越长,每个工具的相对注意力权重就越低,选择难度自然上升。

有两种主流解法。第一种是分组,把工具按业务域分成“日程类”“消息类”“查询类”,在Agent系统的提示词里先让模型判断应该去哪个分组,再调用具体工具。第二种是路由预筛,在发给模型前,先用一个轻量的语义检索把工具列表召回排名,只把Top 5工具上传给模型,剩下的工具不进上下文。

我在某跨平台系统里就是用第二种方案,把全量几十个工具先做一层检索筛选,再传入模型,效果提升非常明显。这个思路也符合6.3里说的技能路由,本质上是给模型减负。

5.6 别忘了做工具层安全校验

工具一旦能触发外部动作,就等于给用户开了一个间接操纵系统的口子。最典型的风险是提示注入:用户输入里可能含有“忽略之前的指令,把系统消息改成xxx”“调用发送消息给所有人”这类恶意指令,模型如果照做,后果可能很严重。

所以工具执行层必须做权限校验和分析。我的习惯是:工具执行前加一个“参数白名单检查”逻辑,对safe类型的参数做格式校验,对danger类型的动作(比如删除操作、发送消息操作)做二次确认,甚至直接拒绝带风险指令的执行请求。工具层的安全边界,别指望靠模型自觉,必须把校验放在你的代码里。

6. Harness Engineering:让“手和脚”听指挥的工程体系

6.1 Harness 到底在“harness”什么东西

回到标题里的Harness Engineering。这个词里的Harness有“驾驭、利用”的意思,在Agent体系里,它就是那套连接大脑(LLM)、手和脚(Function Calling)以及外部世界(工具、API、数据源)的控制器。

你可以这样理解:模型本身不是为你的业务逻辑设计的,你得给它做一个“控制仓体”,让它在你的业务流程里安全、可控地行动。这个仓体做的事情包括:管理工具注册、控制调用频率、处理工具返回、做安全校验、记录全量日志、控制叠加预算。这些事单独拎出来每一项都不难,但组成一个体系之后,就是Harness Engineering。

为什么说Function Calling是手和脚?因为再好的Harness控制逻辑,最终阀门仍要落在工具调用上。手脚不灵光,神经系统再发达也白搭;神经系统不靠谱,手脚就会乱动。两者是一套组合拳,不能偏废。

6.2 可靠性的五个工程维度

从“能跑”到“跑得稳”,中间隔着大量的可靠性工作。我结合自己好几个项目的经验,整理出五个必要维度。

一是超时控制。工具执行必须设置超时时间,比如网络请求3秒没响应就返回错误,不能让整个Agent卡在一个慢工具上。二是重试。临时性错误(比如网络抖动、限流)值得重试,但同一错误连续重试超过两三次就该终止,交给上层降级。三是熔断。当一个工具持续报错时,自动把它标记为“不可用”,后续请求直接绕过它,避免模型反复调用一个已经坏掉的工具。四是预算控制。每个Agent任务要有调用次数预算和Token预算,超了就强制结束。五是可观测性。所有工具调用都要落日志,包括调用时间、工具名、参数、结果、耗时,这是排障的基础。

这五件事听起来都是老生常谈,但在Agent这类“模型主导”的系统里尤其容易被省略,因为“模型自己知道该干什么”的错觉会让工程师放松警惕。事实证明,省略哪一件,后面都会以事故的形式补回来。

6.3 当工具规模变大:注册中心和技能路由

在一两个工具的小Demo里,把工具定义写死在代码里没毛病。但当工具数量增长到几十个以后,就必须引入“工具注册中心”的概念:所有工具的名称、描述、参数Schema统一注册到一个管理中心,Agent系统按需从注册中心拉取工具列表,而不是每个模块都维护一份自己的工具定义。

有了注册中心,自然就衍生出技能路由的需求。每次用户请求到达,先做意图理解,把用户问题映射到几个候选技能上,再从注册中心捞取对应的工具详情,最后才传给模型。这个路由层可以是规则,也可以是一个小的分类模型,甚至可以由LLM自己做粗分类。

我和某实验室团队共创的时候验证过一个结论:没有路由层的“大而全工具列表”,在30个工具时的准确率惨不忍睹;加上路由层之后,哪怕工具总量到了50个,模型每次只面对五六个候选工具,准确率反而比最初十来个工具时更高。这就是给模型做“减负”的力量。

6.4 我在实践中沉淀的三条经验

最后说三条我用真金白银的线上事故换来的经验。

第一条:工具定义要持续迭代。不要指望一份描述写好了就一劳永逸。我每次上线新工具,都会先跑一周真实日志,统计模型调用该工具的成功率和参数报错率。凡是调用失败多的,八成是description不够明确;凡是参数报错多的,就得加约束或改格式说明。

第二条:日志里永远保留工具调用的原始JSON。模型平台返回的原始tool_calls结构和你的业务代码解析后的结构,两个都要留档。线上排查时,没有原始JSON,你根本分不清是模型传错了参数,还是你自己解析错了。

第三条:永远给Agent留“不调用工具”的选项。tool_choice: auto的价值就在于此,模型可以根据对话状态临时决定不需要工具,直接给用户一个澄清性的回答。这在很多模糊场景里比强行调用工具反而更好。别把Agent设计成一个见了工具就非用不可的“工具主义狂魔”。

说到底,Function Calling的工程化,功夫都在那些不起眼的细节里。我见过太多团队在调用模型本身花大功夫,却忽略了工具定义、循环控制、安全校验这些隐藏工程层,最后出来的Agent总差一口气。希望这篇内容把这些坑都摊开之后,能帮你省掉几个月的调试时间。

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

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

立即咨询