【AI】Agent 全栈进阶|工具调用与结构化输出 ◆ 博主名称 QuZhengRongAI俘虏样式苦手⭐️ 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_KEYsk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx BASE_URLhttps://dashscope.aliyuncs.com/compatible-mode/v1添加三个工具算食材成本、查门店营业额、查加盟品牌口碑# 工具定义与注册演示 LangChain 中三种定义工具的方式并打印工具元信息fromdotenvimportload_dotenvimportosfromlangchain.toolsimporttoolfromlangchain_core.toolsimportStructuredToolfromlangchain_openaiimportChatOpenAIfrompydanticimportBaseModel,FieldfromtypingimportLiteral load_dotenv()# 方式一tool 装饰器 docstring # 最简单的写法工具名默认取函数名描述取 docstringtooldefcalculate_cost(expression:str)-str:计算一个餐饮成本表达式例如 15*2003000 算一天食材成本。try:resulteval(expression)returnf算出来了{expression}{result}元exceptExceptionase:returnf算不了{e}# 方式二tool Pydantic args_schema # 用 Schema 类约束参数类型、枚举、默认值、描述都写清楚classStoreRevenueInput(BaseModel):store_name:strField(description门店名称例如 深圳南山店)metric:Literal[daily,monthly]Field(defaultdaily,description查询周期daily 日营业额 / monthly 月营业额)tool(args_schemaStoreRevenueInput)defget_store_revenue(store_name:str,metric:strdaily)-str:查询指定门店的营业额数据。# 模拟查门店数据amount8500ifmetricdailyelse255000returnf{store_name}{metric}营业额{amount}元# 方式三StructuredTool.from_function # 适合把现成函数包装成工具不用改原函数定义defcheck_franchise(brand:str)-str:查询加盟品牌的口碑和风险情况。returnf{brand}加盟费 18 万网上投诉集中在供应链口碑中等偏下franchise_toolStructuredTool.from_function(funccheck_franchise,namecheck_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. 把工具绑定到模型上模型就知道有这些工具可用了llmChatOpenAI(modelqwen-plus,api_keyos.getenv(API_KEY),base_urlos.getenv(BASE_URL))llm_with_toolsllm.bind_tools(tools)# 3. 问一个需要调工具的问题看模型返回的 tool_calls调用意图responsellm_with_tools.invoke(帮我查一下深圳南山店今天卖了多少钱)print(模型调用意图,response.tool_calls)运行程序验证输出结果1、每个工具注册后都变成了一个带name、description、args_schema的标准对象这是工具的描述。2、bind_tools把工具清单传给模型模型收到问题时会自己判断要不要调工具。3、tool_calls是模型返回的调用意图[{name:get_store_revenue,args:{store_name:深圳南山店,metric:daily},id:xxx}]这就是上一节说的结构化的调用意图模型没执行任何本地代码只是告知程序调用get_store_revenue参数是store_name深圳南山店, metricdaily。三、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()llmChatOpenAI(modelqwen-plus,api_keyos.getenv(API_KEY),base_urlos.getenv(BASE_URL))# 1. 用 Pydantic 定义输出结构# 每个字段的 description 会翻译成 JSON Schema 给模型看模型照着填就不会跑偏# 这是勇哥评估加盟项目最关心的几个数投多少钱、多久回本、风险多大classFranchiseEvaluation(BaseModel):brand:strField(description加盟品牌名称)franchise_fee:floatField(description加盟费单位万元必须是数字不能带单位)payback_months:intField(description预计回本周期单位月必须是数字不能带单位)risk_level:Literal[低,中,高]Field(default中,description风险等级低 / 中 / 高)location:strField(description计划开店城市)# 2. 手动把 Schema 塞进 System Prompt 用 json_mode 保证输出可解析# 百炼对 with_structured_output 的 json_schema 模式支持不稳直接用会报 400# json_mode 只保证输出是合法 json不保证字段名对所以要把 Schema 一起喂给模型schema_jsonFranchiseEvaluation.model_json_schema()system_prompt(你是一个信息抽取助手。请把用户发来的加盟咨询内容抽取成结构化的 json 格式数据。f必须严格按照下面的 JSON Schema 字段名和类型输出\n{json.dumps(schema_json,ensure_asciiFalse)}\n注意数值字段只输出数字不要带单位。)structured_llmllm.with_structured_output(FranchiseEvaluation,methodjson_mode)# 3. 给一段粉丝发来的自然语言让模型抽成结构化数据# 勇哥每天能收到一堆这种私信手动整理太费劲让模型自动抽fan_message勇哥我想加盟甜啦啦奶茶加盟费大概 12 万品牌方说 8 个月能回本我打算在长沙开您觉得风险大不大messages[SystemMessage(contentsystem_prompt),HumanMessage(contentfan_message)]resultstructured_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. 初始化模型并绑定工具llmChatOpenAI(modelqwen-plus,api_keyos.getenv(API_KEY),base_urlos.getenv(BASE_URL))llm_with_toolsllm.bind_tools(tools)# 3. 工具调用循环核心逻辑就一个 while# 思路模型说还要调工具就继续不调了就结束defrun_agent(user_input:str,max_iter:int5)-str:运行最小工具调用循环返回最终回答。 Args: user_input: 用户输入的问题 max_iter: 最大循环次数防止模型无限调工具 Returns: 模型生成的最终回答文本 messages[HumanMessage(contentuser_input)]foriinrange(max_iter):# 第一步把当前消息发给模型模型决定要不要调工具responsellm_with_tools.invoke(messages)messages.append(response)# 没有 tool_calls说明模型已经想好最终答案循环结束ifnotresponse.tool_calls:print(f[第{i1}轮] 模型给出最终回答)returnresponse.content# 第二步模型要求调工具逐个执行forcallinresponse.tool_calls:tool_namecall[name]tool_argscall[args]print(f[第{i1}轮] 调用工具{tool_name}参数{tool_args})# 执行工具拿到结果resulttool_map[tool_name].invoke(tool_args)# 第三步把工具结果以 ToolMessage 喂回消息列表# ToolMessage 的 tool_call_id 要和模型给的对应上模型才知道这是哪个工具的返回messages.append(ToolMessage(contentstr(result),tool_call_idcall[id]))# 回到循环顶部带着工具结果再问模型一次return达到最大循环次数强制结束。# 4. 跑一个会同时触发两个工具的问题# 勇哥的粉丝最爱问这种又想算账又想查数据if__name____main__:answerrun_agent(帮我查一下深圳南山店今天的营业额再算算扣掉食材成本 1500 后净赚多少)print(\n最终回答,answer)运行一下会看到这样的输出[第 1 轮] 调用工具get_store_revenue参数{store_name: 深圳南山店} [第 1 轮] 调用工具calculate_cost参数{expression: 8500-1500} [第 2 轮] 模型给出最终回答 最终回答 深圳南山店今天营业额 8500 元扣掉食材成本 1500 元净赚 7000 元。过程如下1、第 1 轮模型收到问题判断要调两个工具查营业额 算成本返回两条tool_calls2、程序逐个执行工具把结果用ToolMessage回传给消息列表——tool_call_id要和模型给的对应上模型才知道哪条结果对应哪个调用3、第 2 轮带着工具结果再问模型模型这次不再返回tool_calls直接给出最终回答循环结束数据说明max_iter是兜底防止模型反复调工具陷入死循环。生产环境这个限制必须要有。ToolMessage的tool_call_id用来关联工具调用和返回结果写错了模型会对应不上。到这里已经手写了一个能调工具的 Agent 雏形。下一篇 RAG 会给它接上知识库五、总结这一篇解决了让模型从只会说到能动手的四个问题Function Calling 原理模型不执行代码只返回结构化的调用意图执行权在程序手里工具定义与注册三种方式按场景选参数 Schema 越清晰模型越不容易传错JSON Schema 约束输出用 Schema 限制模型的输出格式输出可解析、可控最小工具调用循环手写循环跑通 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