如何让智能体可靠调用外部API?从痛点、原理到工程落地的全流程指南


引言

痛点引入

想象一下你正在开发一个**「AI旅行规划师」智能体**:用户输入「下周从上海去厦门鼓浪屿玩3天,预算4000元,住海景民宿,吃当地特色小吃,避开雨天」。你的智能体立刻拆解任务链:调用天气预报API查厦门下周的雨期→调用OTA(携程/飞猪)API查符合预算和日期的海景民宿→调用地图API规划鼓浪屿岛内步行+电瓶车路线→调用大众点评API排小吃优先级+预约网红店→整合所有信息生成HTML+可交互的旅行清单。

一切看起来完美,结果——

  • 调用天气预报API时,因为API服务商的免费额度用完了,直接返回了429 Too Many Requests,智能体却还在傻傻循环重试;
  • OTA API返回了一个JSON格式错误,系统提示KeyError: ‘available_rooms’,智能体直接崩溃并给用户回了一句「抱歉,我无法理解您的需求」;
  • 大众点评API返回了200条小吃结果,但预算吃紧,智能体随机挑了5个,结果都是人均100+的高端餐厅;
  • 最后整合HTML时,民宿地址里的引号没有转义,用户打开网页直接白屏;
  • 用户发现行程第二天下午下小雨,要求重排,但智能体居然忘记缓存之前查过的、第三天可用的民宿房源,又重新浪费了3次API额度。

是不是血压一下子上来了?这些API调用可靠性问题,是现在所有接入外部工具链的智能体(Agent)从「Demo级玩具」到「生产级产品」必须跨越的一道坎——而且是一道很难的坎:传统软件开发的API调用可靠性问题(限流、超时、格式错误、幂等性……),加上大语言模型(LLM)本身的「非确定性」「幻觉(Hallucination)」「上下文理解偏差」「结构化能力不稳定」,直接让问题复杂度翻了N倍。

根据2024年3月《AWS Bedrock智能体生产实践白皮书》统计,生产环境中70%以上的智能体故障都与外部API调用相关;OpenAI的Function Calling Beta版发布至今(2024年7月),GitHub上针对其可靠性优化的开源项目已经超过1200个,足见行业对这个问题的重视程度。

解决方案概述

那我们该怎么系统性地解决这个问题呢?本文不会只给你零散的「加一个重试装饰器」「用Pydantic做数据验证」这类技巧——当然这些技巧很重要,但如果没有完整的工程体系和理论支撑,它们只能解决局部问题,治标不治本。

本文将从**「从LLM视角分析API调用的本质」出发,构建一个「6层智能体API可靠调用架构」**:

  1. API意图理解层:解决LLM会不会选对API、参数会不会填错的问题;
  2. API输入预处理层:解决幻觉参数、格式不一致、逻辑冲突的问题;
  3. API调用执行层:解决超时、重试、限流、幂等性等传统工程问题;
  4. API输出验证层:解决格式错误、数据缺失、逻辑矛盾、安全隐患的问题;
  5. API上下文与状态管理层:解决数据不一致、上下文丢失、冗余调用的问题;
  6. API错误恢复与降级层:解决故障时如何优雅应对的问题。

每一层我们都会:

  • 深入分析核心痛点背后的LLM+系统原理
  • 给出可落地的工程方案
  • 附上Python源代码(基于LangChain、OpenAI Function Calling、Pydantic、Tenacity等主流工具);
  • 列出最佳实践Tips
  • Mermaid架构图/流程图数学模型辅助理解。

最后,我们会用一个**「简化版AI旅行规划师」**的完整项目案例,把这6层架构串起来,让你看完就能动手实现。

最终效果展示(先看一眼)

通过这套架构,我们的「AI旅行规划师」可以做到:

  1. 意图理解准确率≥98%:不会把「查机票」误判成「查火车票」;
  2. 参数填充零幻觉(至少在规则内):不会把「厦门」写成「厦冂」,不会把预算写成「4000万」;
  3. 传统故障处理率≥99.9%:限流自动退避重试,超时3次后自动降级到备选API;
  4. 输出验证通过率≥99.5%:返回的JSON全符合Pydantic Schema,数据缺失自动补默认值或触发LLM反思;
  5. 上下文管理准确率≥99%:不会忘记用户之前的预算、日期等要求,不会重复调用相同参数的API;
  6. 错误恢复优雅率≥100%(开玩笑):即使所有API都挂了,也会给用户回一句「抱歉,我目前查不到实时信息,但我可以给您一份厦门鼓浪屿的通用攻略」。

准备工作

环境/工具

在开始之前,请确保你的开发环境已经配置好以下工具和依赖库:

工具/依赖库版本要求用途
Python≥3.10主流的LLM和Agent开发语言,语法简洁,生态丰富
pip≥23.0Python包管理器,用于安装依赖库
OpenAI API Key-用于调用GPT-4o(推荐,结构化能力强)或GPT-3.5-turbo
LangChain Core≥0.2.0LangChain的核心模块,提供工具链、Agent的基础抽象
LangChain OpenAI≥0.1.0LangChain对OpenAI API的封装,包括Function Calling、ChatOpenAI等
Pydantic≥2.0.0数据验证库,用于定义API的输入/输出Schema,解决结构化不稳定的问题
Tenacity≥8.2.0重试装饰器库,用于解决超时、限流等临时故障
Python-dotenv≥1.0.0环境变量管理库,用于安全存储API Key等敏感信息
Requests≥2.31.0HTTP请求库,用于调用外部API
Redis≥7.0.0缓存/分布式锁库,用于解决上下文丢失、冗余调用、幂等性的问题(可选)
Postman-API调试工具,用于预先验证外部API的格式和可用性
环境安装步骤
  1. 创建Python虚拟环境(强烈推荐,避免依赖冲突)
    # Windows
    python -m venv agent_api_env
    agent_api_env\Scripts\activate
    
    # macOS/Linux
    python3 -m venv agent_api_env
    source agent_api_env/bin/activate
    
  2. 安装依赖库
    pip install langchain-core langchain-openai pydantic tenacity python-dotenv requests redis
    
  3. 配置环境变量
    在项目根目录下创建一个.env文件,内容如下:
    # OpenAI API配置
    OPENAI_API_KEY=你的OpenAI_API_Key
    OPENAI_MODEL_NAME=gpt-4o-mini  # 开发阶段用gpt-4o-mini省钱,生产环境用gpt-4o
    OPENAI_TEMPERATURE=0.0  # 结构化输出和意图理解阶段用0.0,避免非确定性
    
    # 模拟外部API配置(我们后面会自己写几个模拟API,方便演示)
    MOCK_WEATHER_API_URL=http://localhost:8000/weather
    MOCK_OTA_API_URL=http://localhost:8000/ota
    MOCK_DIANPING_API_URL=http://localhost:8000/dianping
    
    # Redis配置(可选)
    REDIS_HOST=localhost
    REDIS_PORT=6379
    REDIS_DB=0
    
  4. 验证OpenAI API是否可用
    新建一个test_openai.py文件,内容如下:
    from dotenv import load_dotenv
    from langchain_openai import ChatOpenAI
    import os
    
    # 加载环境变量
    load_dotenv()
    
    # 初始化ChatOpenAI
    llm = ChatOpenAI(
        model=os.getenv("OPENAI_MODEL_NAME"),
        temperature=float(os.getenv("OPENAI_TEMPERATURE")),
        api_key=os.getenv("OPENAI_API_KEY")
    )
    
    # 测试调用
    response = llm.invoke("你好,请用一句话介绍自己")
    print(response.content)
    
    运行后如果能看到类似「我是OpenAI开发的GPT-4o-mini,一个人工智能助手,擅长各种语言任务和结构化输出」的回复,说明环境配置成功。

基础知识

本文假设你已经具备以下基础知识,如果没有,可以先去补充学习:

  1. Python编程基础:变量、函数、类、装饰器、异常处理、JSON操作等;
  2. HTTP协议基础:GET/POST请求、状态码(200/400/401/403/404/429/500/503)、请求头/响应头、JSON数据格式等;
  3. 大语言模型(LLM)基础:Prompt Engineering(提示词工程)、Function Calling(工具调用)、Hallucination(幻觉)、Temperature(温度参数)等;
  4. 基础软件工程知识:幂等性、缓存、分布式锁、降级、熔断等。

以下是一些推荐的学习资源链接:


核心概念:从LLM视角分析API调用的本质

在开始讲具体的架构和代码之前,我们必须先搞清楚一个问题:LLM到底是怎么调用外部API的?它和传统软件的API调用有什么本质区别?

很多人对LLM调用API的理解停留在「LangChain封装了Function Calling,我只要把工具定义好,剩下的交给LLM就行」——这种理解是完全错误的,也是导致很多Demo级Agent在生产环境中崩溃的根本原因。

传统软件的API调用:确定性的、指令驱动的

先回忆一下传统软件的API调用流程:

写死代码逻辑

明确参数(来自数据库/用户输入)

检查参数合法性

解析固定格式的响应

写死处理逻辑

开发者

业务代码

API调用层

发送HTTP请求

返回结果给业务代码

给用户展示结果

核心特点

  1. 完全确定性:所有逻辑都是开发者写死的——选哪个API、填什么参数、怎么检查合法性、怎么解析响应、出了问题怎么处理,全都是确定的;
  2. 指令驱动:开发者告诉软件「当用户输入X时,你就执行Y操作」,软件只是执行指令的机器;
  3. 参数来源明确:参数要么来自用户输入的结构化表单(比如下拉框选日期,数字输入框填预算,基本不会有格式错误),要么来自数据库的固定值;
  4. 响应格式固定:API的响应格式是开发者和API服务商提前约定好的JSON Schema,解析逻辑也是写死的,几乎不会出问题。

LLM驱动的API调用:非确定性的、意图驱动的

再看LLM驱动的API调用流程(以OpenAI Function Calling为例):

自然语言输入

理解意图(选工具)、生成参数

可选:预处理参数

可选:验证响应

LLM整合响应

用户

LLM

Tool Calling Layer

发送HTTP请求

返回响应给LLM

自然语言回复给用户

核心特点

  1. 非确定性:所有核心逻辑(选工具、填参数、整合响应)都是LLM通过概率模型生成的——即使是相同的用户输入,LLM也可能会生成不同的工具选择或参数;
  2. 意图驱动:开发者不再告诉LLM「当用户输入X时,你就执行Y操作」,而是告诉LLM「这里有几个工具,每个工具的功能是什么,输入输出是什么,你自己根据用户的意图选择工具」;
  3. 参数来源不确定:参数完全来自LLM对用户自然语言输入的理解——可能会有幻觉(比如把「厦门」写成「厦冂」)、格式错误(比如把「2024-08-10」写成「2024/8/10」或「明天」)、逻辑冲突(比如预算4000元却选了人均2000元的民宿);
  4. 响应理解不确定:LLM对API响应的理解也是非确定性的——如果响应格式不对或数据缺失,LLM可能会编造数据,也可能会直接崩溃;
  5. 上下文依赖强:LLM的工具选择和参数填充依赖于之前的对话上下文——如果上下文丢失,LLM可能会忘记用户之前的预算、日期等要求。

概念结构与核心要素组成

既然LLM驱动的API调用和传统软件的有本质区别,那我们就需要重新定义一下它的核心概念和要素组成:

核心概念
  1. Agent(智能体):本文中特指接入外部API工具链的LLM应用,它可以根据用户的自然语言意图,自主选择工具、生成参数、执行调用、整合响应、回复用户;
  2. Tool(工具):Agent可以调用的外部能力的封装,包括API工具(比如天气预报API、OTA API)、代码执行工具(比如Python REPL)、数据库查询工具等——本文只讨论API工具;
  3. Tool Schema(工具定义):用来描述工具功能、输入参数、输出格式的结构化数据,通常是JSON Schema或Pydantic Model,是LLM理解工具的核心;
  4. Function Calling(工具调用):LLM的一种能力,当用户的意图需要使用外部工具时,LLM会生成一个JSON格式的「工具调用请求」,而不是直接回复自然语言;
  5. Tool Response(工具响应):API返回的结果,通常是JSON格式,需要Agent验证后再传给LLM;
  6. Hallucination(幻觉):LLM生成的不符合事实、不符合逻辑、不符合工具要求的数据或内容;
  7. Idempotency(幂等性):指对同一个API调用请求,执行一次和执行多次的结果是完全相同的——这对于解决重试带来的副作用问题非常重要;
  8. Degradation(降级):指当某个API工具不可用时,Agent自动切换到一个功能类似但可靠性/精度稍低的备选工具,或者返回一个预定义的默认内容;
  9. Circuit Breaker(熔断器):指当某个API工具的错误率超过阈值时,Agent暂时停止调用该工具,直接返回降级内容,避免浪费资源和影响用户体验;
  10. Context Window(上下文窗口):指LLM可以处理的最大Token数,超过这个数的对话内容会被LLM遗忘——这是导致上下文丢失的主要原因。
核心要素组成(6层架构的雏形)

基于以上核心概念,我们可以把LLM驱动的API可靠调用拆解成6个核心要素,对应后面要讲的6层架构:

  1. 准确的意图理解与工具选择:对应第1层「API意图理解层」;
  2. 合法的、逻辑一致的参数生成:对应第2层「API输入预处理层」;
  3. 稳定的API调用执行:对应第3层「API调用执行层」;
  4. 可信的、格式正确的响应验证:对应第4层「API输出验证层」;
  5. 完整的上下文与状态管理:对应第5层「API上下文与状态管理层」;
  6. 优雅的错误恢复与降级:对应第6层「API错误恢复与降级层」。

概念之间的关系:ER实体关系图与交互流程图

ER实体关系图

initiates

contains

may_contain

may_contain

has

manages

is_called_by

generates

is_defined_by

is_validated_by

uses

uses

USER

CONVERSATION

MESSAGE

TOOL_CALL

TOOL_RESPONSE

AGENT

TOOL

TOOL_SCHEMA

VALIDATION_RULE

CACHE

CIRCUIT_BREAKER

交互流程图(完整的6层架构交互)

用户输入自然语言

API意图理解层
1. 检索对话上下文
2. 理解用户意图
3. 匹配最佳工具
4. 生成初始参数

是否需要调用工具?

直接生成自然语言回复给用户

API输入预处理层
1. 参数幻觉检测
2. 参数格式转换
3. 参数逻辑验证
4. 参数补全/修正

参数是否合法?

触发LLM反思:重新生成参数
或要求用户补充信息

API上下文与状态管理层
1. 检查缓存:是否有相同参数的响应?
2. 获取幂等性ID
3. 检查对话状态是否允许调用

缓存是否命中?

直接从缓存中读取响应

API调用执行层
1. 检查熔断器状态
2. 发送HTTP请求(加超时、重试装饰器)
3. 处理临时错误(429/500/503)
4. 写入日志

API调用是否成功?

API输出验证层
1. 格式验证(Pydantic)
2. 数据完整性验证
3. 数据逻辑验证
4. 安全隐患检测

API错误恢复与降级层
1. 判断错误类型:临时/永久
2. 临时错误:重试(Tenacity)
3. 永久错误:切换备选工具/返回默认内容
4. 更新熔断器状态

降级是否成功?

生成优雅的错误回复给用户

响应是否合法?

触发LLM反思:重新调用工具(换参数)
或要求用户确认信息

API上下文与状态管理层
1. 将响应写入缓存
2. 更新对话状态
3. 裁剪对话上下文(避免超过Context Window)

将响应传给LLM
LLM整合所有信息生成自然语言回复

将自然语言回复和对话历史存入数据库
回复给用户


第1层:API意图理解层——解决会不会选对工具、填错参数的问题

API意图理解层是整个架构的第一道门槛——如果LLM选不对工具,后面的所有工作都是白搭;如果LLM生成的初始参数错误率太高,后面的预处理层也会不堪重负。

核心痛点

  1. 工具选择错误:比如用户说「我要订机票」,LLM却选了「订火车票」的工具;或者用户说「我要查上海到北京的航班」,LLM同时选了「查航班」和「查火车票」的工具(重复调用);
  2. 初始参数填充错误率高:比如工具要求参数是「date: YYYY-MM-DD」,LLM却生成了「date: 明天」或「date: 2024/8/10」;比如工具要求参数是「budget: float(元)」,LLM却生成了「budget: 4000万」或「budget: 四千元」;
  3. 上下文理解偏差:比如用户之前说「预算4000元,不含机票」,LLM在后面查民宿时却把机票钱也算进去了;
  4. 多余工具调用:比如用户只是问「厦门鼓浪屿好玩吗?」,LLM却调用了「查天气预报」和「查民宿」的工具(虽然用户没有明确要求)。

背后的原理

为什么LLM会出现这些问题呢?我们从Prompt EngineeringTool Schema设计LLM本身的能力三个角度来分析:

1. Prompt Engineering角度

很多开发者在写Agent的System Prompt(系统提示词)时,写得太笼统了,比如:

「你是一个AI旅行规划师,这里有几个工具,你可以根据用户的需求选择工具。」

这样的Prompt没有给LLM足够的约束示例,LLM只能靠概率模型来选择工具和填充参数——自然会出错。

根据OpenAI官方Prompt Engineering指南,好的System Prompt应该包含以下几个部分:

  1. 角色定义:明确LLM的身份和职责;
  2. 约束条件:明确LLM不能做什么,比如「不要编造数据」「不要调用多余的工具」;
  3. 工具使用说明:明确LLM什么时候应该使用工具,什么时候应该直接回复;
  4. 示例对话:给LLM提供几个「好的」和「坏的」示例对话,让LLM学习如何正确地选择工具和填充参数;
  5. 输出格式要求:如果需要结构化输出(比如工具调用后的整合回复),明确输出格式。
2. Tool Schema设计角度

Tool Schema是LLM理解工具的唯一途径——如果Schema写得不好,LLM根本不知道该怎么用这个工具。

很多开发者在写Tool Schema时,犯了以下几个错误:

  1. 工具功能描述太笼统:比如把「查天气」的工具功能描述成「查询天气信息」,而不是「查询指定城市在指定日期范围内的天气预报,包括温度、降水概率、风力等信息」;
  2. 参数描述太模糊:比如把「date」参数的描述写成「日期」,而不是「需要查询的日期,格式必须是YYYY-MM-DD,比如2024-08-10」;
  3. 没有明确必填参数和可选参数:LLM可能会漏掉必填参数,或者给可选参数填一些没用的值;
  4. 没有给参数设置枚举值(Enum):比如「city」参数,LLM可能会生成「厦冂」「厦门岛」「厦门市思明区」等不同的写法,如果设置了枚举值(比如[“上海”, “北京”, “厦门”, “广州”]),LLM就不会出错了;
  5. 没有给参数设置最小值/最大值:比如「budget」参数,LLM可能会生成「-100」或「1000000000」的值,如果设置了最小值0和最大值100000,LLM就会收敛很多。
3. LLM本身的能力角度

即使Prompt和Schema都写得很好,LLM本身的能力也有限制:

  1. 非确定性:即使是相同的用户输入和相同的Prompt,LLM也可能会生成不同的工具选择或参数——解决办法是把Temperature设置成0.0;
  2. Context Window限制:如果对话历史太长,超过了LLM的Context Window,LLM就会忘记之前的约束条件和用户要求——解决办法是对对话历史进行「摘要」或「裁剪」,只保留最近的、最重要的内容;
  3. 结构化能力不稳定:虽然GPT-4o和GPT-3.5-turbo的结构化能力已经很强了,但在处理复杂的Schema(比如嵌套的JSON Schema)时,还是可能会出错——解决办法是用「结构化输出强制模式」(比如OpenAI的JSON Mode或Structured Outputs),或者用Pydantic做验证。

工程方案

基于以上原理分析,我们可以提出以下几个可落地的工程方案:

方案1:写一份「高质量的System Prompt」

这里我们给「AI旅行规划师」写一份高质量的System Prompt,包含所有必要的部分:

SYSTEM_PROMPT = """
你是一个专业的、可靠的AI旅行规划师,你的名字叫「小驴」。

### 1. 角色定义与职责
你的主要职责是根据用户的自然语言需求,规划合理的旅行方案,包括但不限于:
- 查询指定城市在指定日期范围内的天气预报;
- 查询符合预算、日期、位置等要求的酒店/民宿;
- 查询当地特色小吃和景点;
- 规划旅行路线。

### 2. 严格的约束条件
- **永远不要编造数据**:如果你不知道某个信息,或者工具返回的信息不完整,请明确告诉用户,不要自己瞎编;
- **不要调用多余的工具**:只有当用户的需求明确需要外部实时信息或你无法直接回答时,才调用工具——比如用户只是问「厦门鼓浪屿好玩吗?」,你可以直接回答,不需要调用任何工具;
- **严格遵守Tool Schema的要求**:工具的参数格式必须和Schema完全一致,必填参数不能少,可选参数如果不需要可以不填;
- **优先考虑用户之前的对话上下文**:不要忘记用户之前提到的预算、日期、旅行目的地等要求。

### 3. 工具使用说明
你有以下几个工具可以使用:
1.  **get_weather**:查询指定城市在指定日期范围内的天气预报;
2.  **search_accommodation**:查询符合要求的酒店/民宿;
3.  **search_food**:查询当地特色小吃;
4.  **search_attractions**:查询当地景点。

什么时候应该使用工具?
- 当用户的需求涉及到「实时信息」(比如天气预报、酒店房间 availability)时;
- 当用户的需求涉及到「外部数据」(比如当地特色小吃、景点)时;
- 当你无法直接回答用户的问题时。

什么时候应该直接回复?
- 当用户的需求只是「闲聊」时;
- 当用户的需求只是「询问常识性问题」时;
- 当用户的需求不需要外部实时信息或数据时。

### 4. 示例对话
#### 示例1:正确的工具选择和参数填充
**用户**:下周从上海去厦门鼓浪屿玩3天,预算4000元,住海景民宿,吃当地特色小吃,避开雨天。
**小驴(工具调用请求)**:
```json
{
  "tool_calls": [
    {
      "id": "call_123",
      "type": "function",
      "function": {
        "name": "get_weather",
        "arguments": {
          "city": "厦门",
          "start_date": "2024-08-12",
          "end_date": "2024-08-14"
        }
      }
    }
  ]
}

(假设今天是2024-08-10)

示例2:错误的工具选择和参数填充(不要这样做)

用户:下周从上海去厦门鼓浪屿玩3天,预算4000元,住海景民宿,吃当地特色小吃,避开雨天。
小驴(工具调用请求)

{
  "tool_calls": [
    {
      "id": "call_456",
      "type": "function",
      "function": {
        "name": "search_flights",
        "arguments": {
          "departure": "上海",
          "arrival": "厦冂",
          "date": "明天",
          "budget": "四千元"
        }
      }
    },
    {
      "id": "call_789",
      "type": "function",
      "function": {
        "name": "search_attractions",
        "arguments": {
          "city": "北京"
        }
      }
    }
  ]
}

错误点

  • 没有这个工具「search_flights」;
  • 城市写成了「厦冂」(幻觉);
  • 日期写成了「明天」(不符合Schema要求的YYYY-MM-DD);
  • 预算写成了「四千元」(不符合Schema要求的float);
  • 调用了多余的工具「search_attractions」,而且城市写成了「北京」(上下文理解偏差)。

5. 输出格式要求

如果你需要调用工具,请严格按照JSON格式输出,不要添加任何额外的自然语言内容;
如果你不需要调用工具,请直接用自然语言回复用户,语气要友好、专业。
“”"


#### 方案2:用Pydantic v2定义「高质量的Tool Schema」
Pydantic v2是一个非常强大的数据验证库,它可以:
1.  自动将Python类转换成JSON Schema(OpenAI Function Calling需要的格式);
2.  自动验证参数的类型、格式、取值范围等;
3.  支持嵌套的JSON Schema;
4.  支持自定义验证逻辑。

这里我们用Pydantic v2定义「AI旅行规划师」的4个工具的Schema:

首先,我们需要导入一些必要的模块:
```python
from typing import List, Optional
from pydantic import BaseModel, Field, field_validator, ValidationError
from datetime import date, timedelta

然后,我们定义几个通用的Schema:

# 通用的位置Schema
class Location(BaseModel):
    city: str = Field(..., description="城市名称,必须是中国大陆的主要城市,比如上海、北京、厦门", enum=["上海", "北京", "厦门", "广州", "深圳", "杭州", "成都", "西安"])
    district: Optional[str] = Field(None, description="区县名称,比如思明区、浦东新区")

# 通用的日期范围Schema
class DateRange(BaseModel):
    start_date: date = Field(..., description="开始日期,格式必须是YYYY-MM-DD,比如2024-08-10")
    end_date: date = Field(..., description="结束日期,格式必须是YYYY-MM-DD,比如2024-08-14")

    # 自定义验证逻辑:结束日期必须晚于开始日期,且日期范围不能超过7天
    @field_validator('end_date')
    @classmethod
    def end_date_must_be_after_start_date(cls, v: date, info) -> date:
        if 'start_date' in info.data and v <= info.data['start_date']:
            raise ValueError('结束日期必须晚于开始日期')
        if 'start_date' in info.data and (v - info.data['start_date']).days > 7:
            raise ValueError('日期范围不能超过7天')
        return v

接下来,我们定义4个工具的输入Schema:

# 工具1:get_weather的输入Schema
class GetWeatherInput(BaseModel):
    location: Location = Field(..., description="需要查询的位置")
    date_range: DateRange = Field(..., description="需要查询的日期范围")

# 工具2:search_accommodation的输入Schema
class SearchAccommodationInput(BaseModel):
    location: Location = Field(..., description="需要查询的位置")
    date_range: DateRange = Field(..., description="入住和退房日期范围")
    budget_per_night: float = Field(..., description="每晚的预算,单位是元,必须大于0且小于等于10000", ge=0.0, le=10000.0)
    room_type: Optional[str] = Field(None, description="房间类型,比如海景房、标准间、大床房", enum=["海景房", "标准间", "大床房", "套房"])
    guest_count: Optional[int] = Field(2, description="入住人数,必须大于0且小于等于10", ge=1, le=10)

# 工具3:search_food的输入Schema
class SearchFoodInput(BaseModel):
    location: Location = Field(..., description="需要查询的位置")
    budget_per_meal: Optional[float] = Field(None, description="每餐的预算,单位是元,必须大于0且小于等于1000", ge=0.0, le=1000.0)
    food_type: Optional[str] = Field(None, description="美食类型,比如当地特色小吃、海鲜、火锅", enum=["当地特色小吃", "海鲜", "火锅", "川菜", "粤菜"])
    top_k: Optional[int] = Field(5, description="返回的结果数量,必须大于0且小于等于20", ge=1, le=20)

# 工具4:search_attractions的输入Schema
class SearchAttractionsInput(BaseModel):
    location: Location = Field(..., description="需要查询的位置")
    attraction_type: Optional[str] = Field(None, description="景点类型,比如自然风光、历史文化、主题公园", enum=["自然风光", "历史文化", "主题公园", "亲子游"])
    top_k: Optional[int] = Field(5, description="返回的结果数量,必须大于0且小于等于20", ge=1, le=20)

然后,我们定义4个工具的输出Schema(虽然OpenAI Function Calling不强制要求输出Schema,但定义它可以帮助我们后面做验证):

# 工具1:get_weather的输出Schema
class DailyWeather(BaseModel):
    date: date = Field(..., description="日期,格式YYYY-MM-DD")
    max_temp: int = Field(..., description="最高温度,单位摄氏度", ge=-50, le=50)
    min_temp: int = Field(..., description="最低温度,单位摄氏度", ge=-50, le=50)
    weather_condition: str = Field(..., description="天气状况,比如晴、多云、小雨", enum=["晴", "多云", "阴", "小雨", "中雨", "大雨", "雷阵雨"])
    precipitation_probability: int = Field(..., description="降水概率,百分比,必须在0到100之间", ge=0, le=100)
    wind_speed: int = Field(..., description="风力,单位公里/小时,必须在0到200之间", ge=0, le=200)

class GetWeatherOutput(BaseModel):
    location: Location = Field(..., description="查询的位置")
    daily_forecasts: List[DailyWeather] = Field(..., description="每日天气预报列表")

# 工具2:search_accommodation的输出Schema
class Accommodation(BaseModel):
    id: str = Field(..., description="酒店/民宿的唯一ID")
    name: str = Field(..., description="酒店/民宿的名称")
    address: str = Field(..., description="酒店/民宿的地址")
    price_per_night: float = Field(..., description="每晚的价格,单位元")
    room_type: str = Field(..., description="房间类型")
    guest_count: int = Field(..., description="可入住人数")
    rating: float = Field(..., description="评分,必须在0到5之间", ge=0.0, le=5.0)
    review_count: int = Field(..., description="评论数量,必须大于等于0", ge=0)
    is_available: bool = Field(..., description="是否有房")

class SearchAccommodationOutput(BaseModel):
    location: Location = Field(..., description="查询的位置")
    date_range: DateRange = Field(..., description="入住和退房日期范围")
    accommodations: List[Accommodation] = Field(..., description="符合要求的酒店/民宿列表")

# 工具3和4的输出Schema这里就省略了,类似工具1和2

最后,我们需要将这些Pydantic Model转换成OpenAI Function Calling需要的格式——LangChain已经给我们提供了一个工具类StructuredTool,可以自动完成这个转换:

from langchain_core.tools import StructuredTool
import requests
from dotenv import load_dotenv
import os

# 加载环境变量
load_dotenv()

# 先定义几个模拟的工具函数(后面我们会自己写模拟API,这里先写个空的)
def mock_get_weather(location: Location, date_range: DateRange) -> GetWeatherOutput:
    # 这里先返回一个空的GetWeatherOutput,后面我们会替换成调用真实的模拟API
    return GetWeatherOutput(location=location, daily_forecasts=[])

def mock_search_accommodation(location: Location, date_range: DateRange, budget_per_night: float, room_type: Optional[str] = None, guest_count: int = 2) -> SearchAccommodationOutput:
    return SearchAccommodationOutput(location=location, date_range=date_range, accommodations=[])

def mock_search_food(location: Location, budget_per_meal: Optional[float] = None, food_type: Optional[str] = None, top_k: int = 5) -> dict:
    return {}

def mock_search_attractions(location: Location, attraction_type: Optional[str] = None, top_k: int = 5) -> dict:
    return {}

# 将Pydantic Model转换成StructuredTool
get_weather_tool = StructuredTool.from_function(
    func=mock_get_weather,
    name="get_weather",
    description="查询指定城市在指定日期范围内的天气预报,包括温度、降水概率、风力等信息",
    args_schema=GetWeatherInput,
    return_direct=False  # 工具返回的结果需要传给LLM整合,而不是直接返回给用户
)

search_accommodation_tool = StructuredTool.from_function(
    func=mock_search_accommodation,
    name="search_accommodation",
    description="查询符合预算、日期、位置、房间类型等要求的酒店/民宿",
    args_schema=SearchAccommodationInput,
    return_direct=False
)

search_food_tool = StructuredTool.from_function(
    func=mock_search_food,
    name="search_food",
    description="查询当地特色小吃、海鲜等美食",
    args_schema=SearchFoodInput,
    return_direct=False
)

search_attractions_tool = StructuredTool.from_function(
    func=mock_search_attractions,
    name="search_attractions",
    description="查询当地自然风光、历史文化等景点",
    args_schema=SearchAttractionsInput,
    return_direct=False
)

# 把所有工具放在一个列表里
tools = [get_weather_tool, search_accommodation_tool, search_food_tool, search_attractions_tool]
方案3:使用「结构化输出强制模式」和「Temperature=0.0」

虽然Prompt和Schema都写得很好了,但LLM还是可能会生成不符合格式的工具调用请求——这时候我们就需要使用「结构化输出强制模式」。

OpenAI提供了两种结构化输出强制模式:

  1. JSON Mode:强制LLM返回JSON格式的内容,但不强制JSON的结构(需要自己验证);
  2. Structured Outputs:强制LLM返回符合指定JSON Schema的内容(2024年7月刚发布的新功能,只有GPT-4o和GPT-4o-mini支持,非常强大)。

这里我们推荐使用Structured Outputs,因为它可以从根源上解决LLM生成不符合格式的工具调用请求的问题。

另外,我们需要把Temperature设置成0.0,这样可以最大限度地减少LLM的非确定性——相同的用户输入和Prompt,LLM会生成完全相同的工具选择和参数。

我们来修改一下之前初始化的ChatOpenAI:

from langchain_openai import ChatOpenAI

# 初始化支持Structured Outputs的ChatOpenAI
llm = ChatOpenAI(
    model=os.getenv("OPENAI_MODEL_NAME"),
    temperature=float(os.getenv("OPENAI_TEMPERATURE")),
    api_key=os.getenv("OPENAI_API_KEY"),
    # 启用Structured Outputs(只有GPT-4o和GPT-4o-mini支持)
    # 注意:LangChain目前对Structured Outputs的封装还不是很完善,我们后面会用OpenAI的原生API来演示
)
方案4:使用「对话历史摘要」或「对话历史裁剪」避免Context Window限制

随着对话的进行,对话历史会越来越长,超过LLM的Context Window——这时候LLM就会忘记之前的约束条件和用户要求。

解决这个问题的方法有两个:

  1. 对话历史摘要:定期(比如每10轮对话)用LLM对之前的对话历史进行摘要,只保留最重要的内容(比如用户的预算、日期、旅行目的地、之前的工具调用结果等);
  2. 对话历史裁剪:只保留最近的N轮对话(比如最近的20轮),或者只保留最近的X个Token(比如最近的128000个Token,GPT-4o的Context Window是128000个Token)。

这里我们先讲一下对话历史裁剪的实现方法——LangChain已经给我们提供了一个工具类ConversationBufferWindowMemory,可以自动裁剪对话历史,只保留最近的K轮对话:

from langchain_core.memory import ConversationBufferWindowMemory

# 初始化ConversationBufferWindowMemory,只保留最近的5轮对话
memory = ConversationBufferWindowMemory(
    k=5,  # 保留最近的5轮对话
    return_messages=True,  # 返回Message对象,而不是字符串
    memory_key="chat_history",  # 对话历史的key,后面会用到
    output_key="output"  # 输出的key,后面会用到
)

对话历史摘要的实现方法稍微复杂一点,我们后面会在「第5层:API上下文与状态管理层」详细讲解。

最佳实践Tips

  1. 角色定义要具体:不要只说「你是一个
Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐