三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

【AI】Agent 全栈进阶|工具调用与结构化输出

【AI】Agent 全栈进阶|工具调用与结构化输出

◆ 博主名称: QuZhengRong
AI俘虏,样式苦手

⭐️ Agent专栏: Agent
⭐️ LuckReport专栏: LuckReport
⭐️ SpringBoot专栏: SpringBoot

目录

  • 一、Function Calling 原理
  • 二、工具(Tool)的定义与注册
  • 三、JSON Schema 约束输出
  • 四、实现最小化的工具调用循环
  • 五、总结
  • 六、LuckReport 项目推荐
    • 1、项目简介
    • 2、在线体验

上一篇我们演示了如何调用大模型接口,让大模型回答问题,但从 Agnet 视角出发,它还存在一个很明显的问题:只会说,不会做

你让大模型搜集数据在电脑上新建一个当日营业额汇总表,它是做不到的,它还缺少和外部交互的"手脚"

要让模型能真正去查数据、跑计算、调接口,还得靠这一篇的主角:Function Calling 与结构化输出

一、Function Calling 原理

Function Calling 是大模型的一项关键受控输出能力,其核心作用是对模型的输出范式进行约束,使其能够将自然语言形式的用户需求,映射为结构化的函数调用意图

What does it mean ?举一个例子:你问大模型深圳南山店今天营业额多少,模型并不清楚具体的营收数据,而程序里刚好有一个 get_store_revenue 函数可以从系统查询营业额,那模型就会返回给你一段这样的结构化数据:

{"name":"get_store_revenue","arguments":{"store_name":"深圳南山店"}}

程序拿到这段意图后,自己去调对应的函数,再把结果回传给大模型,模型再给出最终回答

模型只判断该不该调工具、调哪个工具、参数填什么,真正的执行权在程序手上。整个链路是这样的:

模型返回的这段意图必须是可解析的结构化数据,不能是一堆自然语言,否则程序无法统一处理,所以 Function Calling 背后还依赖一个能力:结构化输出。让模型按你定的格式返回数据,是这一篇要解决的核心问题


二、工具(Tool)的定义与注册

模型调用工具的前提是知道哪些工具可用、每个工具怎么用,这就是工具的定义与注册

LangChain 里定义工具有三种常见方式,先准备环境:

pipinstalllangchain langchain-openai python-dotenv pydantic

.env沿用上一篇阿里百炼的配置:

API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1

添加三个工具:算食材成本、查门店营业额、查加盟品牌口碑

# 工具定义与注册:演示 LangChain 中三种定义工具的方式,并打印工具元信息fromdotenvimportload_dotenvimportosfromlangchain.toolsimporttoolfromlangchain_core.toolsimportStructuredToolfromlangchain_openaiimportChatOpenAIfrompydanticimportBaseModel,FieldfromtypingimportLiteral load_dotenv()# ========== 方式一:@tool 装饰器 + docstring ==========# 最简单的写法,工具名默认取函数名,描述取 docstring@tooldefcalculate_cost(expression:str)->str:"""计算一个餐饮成本表达式,例如 '15*200+3000' 算一天食材成本。"""try:result=eval(expression)returnf"算出来了:{expression}={result}元"exceptExceptionase:returnf"算不了:{e}"# ========== 方式二:@tool + Pydantic args_schema ==========# 用 Schema 类约束参数:类型、枚举、默认值、描述都写清楚classStoreRevenueInput(BaseModel):store_name:str=Field(description="门店名称,例如 '深圳南山店'")metric:Literal["daily","monthly"]=Field(default="daily",description="查询周期:daily 日营业额 / monthly 月营业额")@tool(args_schema=StoreRevenueInput)defget_store_revenue(store_name:str,metric:str="daily")->str:"""查询指定门店的营业额数据。"""# 模拟查门店数据amount=8500ifmetric=="daily"else255000returnf"{store_name}{metric}营业额{amount}元"# ========== 方式三:StructuredTool.from_function ==========# 适合把现成函数包装成工具,不用改原函数定义defcheck_franchise(brand:str)->str:"""查询加盟品牌的口碑和风险情况。"""returnf"{brand}:加盟费 18 万,网上投诉集中在供应链,口碑中等偏下"franchise_tool=StructuredTool.from_function(func=check_franchise,name="check_franchise",description="查询某个加盟品牌的加盟费、口碑、风险信息")# 1. 打印三个工具的元信息:名字、描述、参数 Schematools=[calculate_cost,get_store_revenue,franchise_tool]fortintools:print(f"工具名:{t.name}")print(f"描述:{t.description}")print(f"参数 Schema:{t.args_schema.model_json_schema()}")print("-"*60)# 2. 把工具绑定到模型上,模型就知道有这些工具可用了llm=ChatOpenAI(model="qwen-plus",api_key=os.getenv("API_KEY"),base_url=os.getenv("BASE_URL"))llm_with_tools=llm.bind_tools(tools)# 3. 问一个需要调工具的问题,看模型返回的 tool_calls(调用意图)response=llm_with_tools.invoke("帮我查一下深圳南山店今天卖了多少钱")print("模型调用意图:",response.tool_calls)

运行程序,验证输出结果:

1、每个工具注册后都变成了一个带namedescriptionargs_schema的标准对象,这是工具的描述。

2、bind_tools把工具清单传给模型,模型收到问题时会自己判断要不要调工具。

3、tool_calls是模型返回的调用意图:

[{'name':'get_store_revenue','args':{'store_name':'深圳南山店','metric':'daily'},'id':'xxx'}]

这就是上一节说的结构化的调用意图,模型没执行任何本地代码,只是告知程序调用get_store_revenue,参数是store_name=深圳南山店, metric=daily


三、JSON Schema 约束输出

工具调用只是结构化输出的一个场景,另外还有一个常见的场景:让模型把一段自然语言直接转成结构化数据

大模型输出结构化的数据有什么用呢?example:用大模型做提问相关性校验,当判定问题无关业务场景时触发默认应答,大模型按约束以布尔值 true/false 返回校验结果,后端程序即可直接解析标识、执行对应业务分支

下面是代码案例:勇哥每天收到一堆粉丝私信咨询加盟项目,手动整理太费劲,希望模型把粉丝的描述直接抽成一张评估表:

{"brand":"甜啦啦","franchise_fee":12.0,"payback_months":8,"risk_level":"高","location":"长沙"}

描述通常不是一段能直接交给程序解析的文本。这就轮到JSON Schema出场了

JSON Schema 的作用是告诉模型:输出里有哪些字段、每个字段是什么类型、哪些值合法。模型按照这份说明书填空,数据就不会跑偏

LangChain 里用with_structured_output来控制格式化数据

# 结构化输出:演示用 JSON Schema 约束大模型输出,把自然语言变成可解析的结构化数据# 主题:勇哥说餐饮——把粉丝发来的加盟项目描述,抽成勇哥能直接判断的结构化评估表fromdotenvimportload_dotenvimportosimportjsonfromlangchain_openaiimportChatOpenAIfromlangchain_core.messagesimportSystemMessage,HumanMessagefrompydanticimportBaseModel,FieldfromtypingimportLiteral load_dotenv()llm=ChatOpenAI(model="qwen-plus",api_key=os.getenv("API_KEY"),base_url=os.getenv("BASE_URL"))# 1. 用 Pydantic 定义输出结构# 每个字段的 description 会翻译成 JSON Schema 给模型看,模型照着填就不会跑偏# 这是勇哥评估加盟项目最关心的几个数:投多少钱、多久回本、风险多大classFranchiseEvaluation(BaseModel):brand:str=Field(description="加盟品牌名称")franchise_fee:float=Field(description="加盟费,单位万元,必须是数字不能带单位")payback_months:int=Field(description="预计回本周期,单位月,必须是数字不能带单位")risk_level:Literal["低","中","高"]=Field(default="中",description="风险等级:低 / 中 / 高")location:str=Field(description="计划开店城市")# 2. 手动把 Schema 塞进 System Prompt + 用 json_mode 保证输出可解析# 百炼对 with_structured_output 的 json_schema 模式支持不稳,直接用会报 400# json_mode 只保证输出是合法 json,不保证字段名对,所以要把 Schema 一起喂给模型schema_json=FranchiseEvaluation.model_json_schema()system_prompt=("你是一个信息抽取助手。请把用户发来的加盟咨询内容,抽取成结构化的 json 格式数据。"f"必须严格按照下面的 JSON Schema 字段名和类型输出:\n{json.dumps(schema_json,ensure_ascii=False)}\n""注意:数值字段只输出数字,不要带单位。")structured_llm=llm.with_structured_output(FranchiseEvaluation,method="json_mode")# 3. 给一段粉丝发来的自然语言,让模型抽成结构化数据# 勇哥每天能收到一堆这种私信,手动整理太费劲,让模型自动抽fan_message="勇哥,我想加盟'甜啦啦'奶茶,加盟费大概 12 万,品牌方说 8 个月能回本,我打算在长沙开,您觉得风险大不大?"messages=[SystemMessage(content=system_prompt),HumanMessage(content=fan_message)]result=structured_llm.invoke(messages)# 4. result 直接就是个 FranchiseEvaluation 对象,字段、类型都对了print("解析结果对象:",result)print(f"品牌:{result.brand}")print(f"加盟费:{result.franchise_fee}万")print(f"回本周期:{result.payback_months}个月")print(f"风险等级:{result.risk_level}")print(f"开店城市:{result.location}")# 5. 看一下背后的 JSON Schema 长什么样# 这个 Schema 才是模型真正读到的约束规则print("\n背后的 JSON Schema:")print(FranchiseEvaluation.model_json_schema())

运行程序,输出的result直接就是一个FranchiseEvaluation对象

最后打印的 JSON Schema 如下:

{"properties":{"brand":{"description":"加盟品牌名称","type":"string"},"franchise_fee":{"description":"加盟费,单位万元","type":"number"},"payback_months":{"description":"预计回本周期,单位月","type":"integer"},"risk_level":{"default":"中","description":"风险等级:低 / 中 / 高","enum":["低","中","高"],"type":"string"},"location":{"description":"计划开店城市","type":"string"}}}

这份 Schema 就是模型真正读到的约束规则。enum限定枚举值、type限定类型、description说明字段含义。结构化输出的本质,就是用 Schema 把模型的输出限制在可控范围内


四、实现最小化的工具调用循环

前面三节都是零件,这一节把它们组装起来,写一个最小的工具调用循环

先回顾下链路:用户输入 → 模型判断要不要调工具 → 返回 tool_calls → 程序执行工具 → 结果回传模型 → 模型生成最终回答

这里的关键是这是个循环:模型调完一个工具,拿到结果后可能还要再调下一个工具,直到它觉得信息够了,不再返回 tool_calls,循环才结束

fromdotenvimportload_dotenvimportosfromlangchain.toolsimporttoolfromlangchain_openaiimportChatOpenAIfromlangchain_core.messagesimportHumanMessage,ToolMessage load_dotenv()# 1. 定义两个工具:成本计算器 + 门店营业额查询# 勇哥的口头禅:先算账再说话,所以这两个工具是他的标配@tooldefcalculate_cost(expression:str)->str:"""计算一个餐饮成本表达式,例如 '8500-15*200' 算日毛利。"""try:returnf"算出来了:{expression}={eval(expression)}元"exceptExceptionase:returnf"算不了:{e}"@tooldefget_store_revenue(store_name:str)->str:"""查询指定门店的日营业额。"""returnf"{store_name}日营业额 8500 元"tools=[calculate_cost,get_store_revenue]# 工具名 -> 函数 的映射表,方便后面按名字调用tool_map={t.name:tfortintools}# 2. 初始化模型并绑定工具llm=ChatOpenAI(model="qwen-plus",api_key=os.getenv("API_KEY"),base_url=os.getenv("BASE_URL"))llm_with_tools=llm.bind_tools(tools)# 3. 工具调用循环:核心逻辑就一个 while# 思路:模型说还要调工具就继续,不调了就结束defrun_agent(user_input:str,max_iter:int=5)->str:"""运行最小工具调用循环,返回最终回答。 Args: user_input: 用户输入的问题 max_iter: 最大循环次数,防止模型无限调工具 Returns: 模型生成的最终回答文本 """messages=[HumanMessage(content=user_input)]foriinrange(max_iter):# 第一步:把当前消息发给模型,模型决定要不要调工具response=llm_with_tools.invoke(messages)messages.append(response)# 没有 tool_calls,说明模型已经想好最终答案,循环结束ifnotresponse.tool_calls:print(f"[第{i+1}轮] 模型给出最终回答")returnresponse.content# 第二步:模型要求调工具,逐个执行forcallinresponse.tool_calls:tool_name=call["name"]tool_args=call["args"]print(f"[第{i+1}轮] 调用工具:{tool_name},参数:{tool_args}")# 执行工具,拿到结果result=tool_map[tool_name].invoke(tool_args)# 第三步:把工具结果以 ToolMessage 喂回消息列表# ToolMessage 的 tool_call_id 要和模型给的对应上,模型才知道这是哪个工具的返回messages.append(ToolMessage(content=str(result),tool_call_id=call["id"]))# 回到循环顶部,带着工具结果再问模型一次return"达到最大循环次数,强制结束。"# 4. 跑一个会同时触发两个工具的问题# 勇哥的粉丝最爱问这种:又想算账又想查数据if__name__=="__main__":answer=run_agent("帮我查一下深圳南山店今天的营业额,再算算扣掉食材成本 1500 后净赚多少")print("\n最终回答:",answer)

运行一下,会看到这样的输出:

[第 1 轮] 调用工具:get_store_revenue,参数:{'store_name': '深圳南山店'} [第 1 轮] 调用工具:calculate_cost,参数:{'expression': '8500-1500'} [第 2 轮] 模型给出最终回答 最终回答: 深圳南山店今天营业额 8500 元,扣掉食材成本 1500 元,净赚 7000 元。

过程如下:

1、第 1 轮模型收到问题,判断要调两个工具(查营业额 + 算成本),返回两条tool_calls
2、程序逐个执行工具,把结果用ToolMessage回传给消息列表——tool_call_id要和模型给的对应上,模型才知道哪条结果对应哪个调用
3、第 2 轮带着工具结果再问模型,模型这次不再返回tool_calls,直接给出最终回答,循环结束

数据说明

  • max_iter是兜底,防止模型反复调工具陷入死循环。生产环境这个限制必须要有。
  • ToolMessagetool_call_id用来关联工具调用和返回结果,写错了模型会对应不上。

到这里已经手写了一个能调工具的 Agent 雏形。下一篇 RAG 会给它接上知识库


五、总结

这一篇解决了让模型从只会说到能动手的四个问题:

  1. Function Calling 原理:模型不执行代码,只返回结构化的调用意图,执行权在程序手里
  2. 工具定义与注册:三种方式,按场景选,参数 Schema 越清晰模型越不容易传错
  3. JSON Schema 约束输出:用 Schema 限制模型的输出格式,输出可解析、可控
  4. 最小工具调用循环:手写循环跑通 User → Model → Tool → Model → User 的完整链路

结构化输出是 Agent 的基础,输出不稳定 Agent 没法正常工作。下一篇进入 RAG,让模型拥有它训练时没见过的知识


六、LuckReport 项目推荐

导航:LuckReport专栏

1、项目简介

Luck-Report 是一款基于开源项目 UReport2 重构的 Java 高性能报表引擎,通过迭代单元格可以实现任意复杂的中国式报表。相较于 UReport2,在技术架构上进行了全新升级,后端基于 SpringBoot 框架开发、前端采用 Vue 框架构建,技术选型贴合当下主流项目开发标准,可精准适配各类实际开发需求。

Luck-Report 提供了全新的基于网页的报表设计器,可以在 Chrome、Firefox、Edge 等各种主流浏览器运行(IE 浏览器除外)。使用 Luck-Report,打开浏览器即可完成各种复杂报表的设计制作。

Luck-Report 基于 Apache-2.0 开源协议开源

2、在线体验

  • 体验地址:https://www.quzhe.top/luck-report/report/designer
  • 源码地址:https://gitee.com/LuckyPools/luck-report
  • 文档地址:https://www.quzhe.top/luck-report-blog/report
← 返回列表