week2 day5 OpenAI 大模型 + Function Calling 工具调用 + SQLite 数据库 + Gradio 网页 UI + 图片生成 + TTS 语音合成

 

 

本项目 = OpenAI 大模型 + Function Calling 工具调用 + SQLite 数据库 + Gradio 网页 UI + 图片生成 + TTS 语音合成,实现航空智能客服 Agent。

 

注意:代码里有重复定义chat()函数,是课程分步迭代演示,后面的会覆盖前面。

# ====================== 导入依赖库 ======================
import os                # 操作系统模块,读取环境变量
import json              # json解析,解析工具调用返回的参数字符串
from dotenv import load_dotenv   # 读取.env配置文件,存放密钥
from openai import OpenAI        # OpenAI官方SDK
import gradio as gr              # Gradio:快速搭建web交互界面
import sqlite3                  # sqlite轻量本地数据库,存放机票价格

# ====================== 初始化部分 ======================
load_dotenv(override=True)       # 加载.env文件环境变量;override=True:覆盖已存在的环境变量

openai_api_key = os.getenv('OPENAI_API_KEY')  # 从环境变量读取OpenAI密钥
if openai_api_key:
    # 如果密钥存在,打印密钥前8位做校验,不打印完整密钥防止泄露
    print(f"OpenAI API Key exists and begins {openai_api_key[:8]}")
else:
    print("OpenAI API Key not set")  # 没读到密钥提示

MODEL = "gpt-4.1-mini"              # 指定使用的大模型名称
openai = OpenAI()                   # 实例化OpenAI客户端;密钥自动从环境变量读取

DB = "prices.db"                    # 定义sqlite数据库文件名,存储各城市机票价格

# 系统提示词:设定AI助手身份、输出规则
system_message = """
You are a helpful assistant for an Airline called FlightAI.
Give short, courteous answers, no more than 1 sentence.
Always be accurate. If you don't know the answer, say so.
"""

# ====================== 工具函数:查询机票价格 ======================
def get_ticket_price(city):
    # 打印日志,标记数据库工具被调用,flush=True立即打印输出,不缓存
    print(f"DATABASE TOOL CALLED: Getting price for {city}", flush=True)
    # with上下文管理数据库连接:执行完自动关闭连接,防止连接泄露
    with sqlite3.connect(DB) as conn:
        cursor = conn.cursor()                     # 获取数据库游标,用来执行SQL
        # SQL查询:根据城市查询票价;?是sqlite参数占位符;city.lower()城市转小写,大小写不敏感
        cursor.execute('SELECT price FROM prices WHERE city = ?', (city.lower(),))
        result = cursor.fetchone()                 # fetchone()获取查询到的第一条结果元组
        # 如果查到数据返回票价字符串;没有查到返回无数据提示
        return f"Ticket price to {city} is ${result[0]}" if result else "No price data available for this city"

get_ticket_price("Paris")    # 测试调用:查询巴黎票价,验证数据库是否正常

# ====================== 定义工具描述(给大模型看的function calling定义) ======================
price_function = {
    "name": "get_ticket_price",                             # 要调用的python函数名字,必须完全匹配
    "description": "Get the price of a return ticket to the destination city.", # 告诉大模型这个工具干什么
    "parameters": {                                         # 工具入参定义,OpenAI标准JSON Schema格式
        "type": "object",
        "properties": {                                     # 参数列表
            "destination_city": {
                "type": "string",
                "description": "The city that the customer wants to travel to",
            },
        },
        "required": ["destination_city"],                   # 必填参数:必须传目的地城市
        "additionalProperties": False                       # 禁止大模型编造额外参数,减少错误
    }
}
tools = [{"type": "function", "function": price_function}] # 组装tools数组,传给chat.completions接口
tools   # jupyter环境下,直接写变量名会输出查看tools内容

# ======================【版本1】基础聊天函数:没有工具调用,纯对话 ======================
def chat(message, history):
    # history是gradio聊天历史,转换成OpenAI标准消息格式:[{"role":"xxx","content":"xxx"}]
    history = [{"role": h["role"], "content": h["content"]} for h in history]
    # 组装完整消息:系统提示词 + 历史对话 + 当前用户消息
    messages = [{"role": "system", "content": system_message}] + history + [{"role": "user", "content": message}]
    # 请求大模型接口,不开启工具调用
    response = openai.chat.completions.create(model=MODEL, messages=messages)
    # 返回大模型生成的文本回答
    return response.choices[0].message.content

# 启动Gradio简易聊天界面,type="messages"使用OpenAI格式消息
gr.ChatInterface(fn=chat, type="messages").launch()

# ======================【版本2】重写chat函数:增加工具调用循环逻辑 ======================
def chat(message, history):
    # 将gradio的history转为OpenAI标准消息格式
    history = [{"role":h["role"], "content":h["content"]} for h in history]
    messages = [{"role": "system", "content": system_message}] + history + [{"role": "user", "content": message}]
    # 调用大模型,传入tools工具列表,允许模型触发工具调用
    response = openai.chat.completions.create(model=MODEL, messages=messages, tools=tools)

    # while循环处理工具调用:finish_reason == "tool_calls"代表模型想要调用工具,不是直接回答
    while response.choices[0].finish_reason=="tool_calls":
        message = response.choices[0].message        # 获取模型返回的tool_call消息对象
        responses = handle_tool_calls(message)       # 执行工具,拿到工具返回结果
        messages.append(message)                     # 把模型发出工具调用请求这条消息加入上下文
        messages.extend(responses)                   # 把工具执行结果全部追加到消息列表
        # 再次把完整消息发给大模型;模型拿到工具结果,生成最终回答
        response = openai.chat.completions.create(model=MODEL, messages=messages, tools=tools)
    
    # 返回最终AI回答文本
    return response.choices[0].message.content

# 处理工具调用:解析模型请求,执行对应函数,组装tool角色消息
def handle_tool_calls(message):
    responses = []                                   # 存放工具返回结果消息列表
    for tool_call in message.tool_calls:             # 遍历每一个工具调用(支持一次调用多个工具)
        if tool_call.function.name == "get_ticket_price": # 判断调用的是票价查询函数
            arguments = json.loads(tool_call.function.arguments) # json字符串解析成python字典
            city = arguments.get('destination_city')  # 获取参数中的目的地城市
            price_details = get_ticket_price(city)    # 调用本地函数查数据库
            # 组装tool角色消息,必须带上tool_call_id和模型请求一一对应,OpenAI强制要求
            responses.append({
                "role": "tool",
                "content": price_details,
                "tool_call_id": tool_call.id
            })
    return responses

# 启动简易聊天界面(带工具调用版本)
gr.ChatInterface(fn=chat, type="messages").launch()

"""
===== Gradio底层原理说明(文本注释) =====
Gradio constructs a frontend Svelte app based on our Python description of the UI
Gradio基于Python写的界面描述自动构建Svelte前端网页应用

Gradio starts a server built upon the Starlette web framework listening on a free port that serves this Svelte app
Gradio启动基于Starlette异步web框架的服务,随机找空闲端口对外提供网页服务

Gradio creates backend routes for our callbacks, like chat(), which calls our functions
Gradio自动生成后端接口路由,绑定我们写的chat回调函数,浏览器请求就会执行python代码

And of course when Gradio generates the frontend app, it ensures that the the Submit button calls the right backend route.
前端页面的提交按钮会自动绑定对应的后端接口路由。
"""

# ====================== 多模态新增:图片生成相关导入 ======================
import base64          # base64编解码,OpenAI图片接口返回base64图片
from io import BytesIO # 内存字节流,不用落地磁盘文件,内存处理图片
from PIL import Image  # PIL图像处理库,加载图片对象

# artist函数:调用OpenAI图片生成接口,生成城市旅游图片
def artist(city):
    image_response = openai.images.generate(
            model="gpt-image-1-mini",     # 指定图像生成模型
            prompt=f"An image representing a vacation in {city}, showing tourist spots and everything unique about {city}, in a vibrant pop-art style", #绘图提示词
            size="1024x1024",              # 输出图片分辨率
            n=1,                           # 生成1张图片
        )
    image_base64 = image_response.data[0].b64_json   # 获取返回图片的base64字符串
    image_data = base64.b64decode(image_base64)      # base64解码为二进制图片字节
    return Image.open(BytesIO(image_data))           # BytesIO把二进制包装成文件流,PIL打开图片,返回图片对象

image = artist("New York City")  # 测试:生成纽约图片
display(image)                   # jupyter环境展示图片

# talker函数:TTS语音合成,把文本转为音频二进制数据
def talker(message):
    response = openai.audio.speech.create(
      model="gpt-4o-mini-tts",   # tts语音模型
      voice="onyx",              # 音色;可选 alloy / coral
      input=message              # 需要转语音的文本
    )
    return response.content     # 返回音频二进制bytes

# ======================【版本3】完整多模态Agent chat函数:聊天+工具调用+图片+语音 ======================
def chat(history):
    # 转换聊天历史为OpenAI标准消息格式
    history = [{"role":h["role"], "content":h["content"]} for h in history]
    messages = [{"role": "system", "content": system_message}] + history
    # 请求大模型,开启工具调用
    response = openai.chat.completions.create(model=MODEL, messages=messages, tools=tools)
    cities = []     # 保存工具调用拿到的城市名称,用来生成图片
    image = None    # 初始化图片对象,默认无图片

    # 循环处理工具调用,支持多轮工具调用
    while response.choices[0].finish_reason=="tool_calls":
        message = response.choices[0].message
        responses, cities = handle_tool_calls_and_return_cities(message) # 执行工具同时收集城市名
        messages.append(message)
        messages.extend(responses)
        response = openai.chat.completions.create(model=MODEL, messages=messages, tools=tools)

    reply = response.choices[0].message.content  # 获取AI最终文本回复
    history += [{"role":"assistant", "content":reply}] # 将AI回答追加到聊天历史

    voice = talker(reply)   # 将AI回答文本转为语音二进制数据

    if cities:              # 如果工具调用拿到了城市,就调用绘图函数生成图片
        image = artist(cities[0])
    
    # 返回更新后的聊天记录、语音二进制、图片对象,给Gradio组件渲染
    return history, voice, image

# 升级版工具处理函数:不仅返回工具结果,同时收集查询过的城市,用于生成图片
def handle_tool_calls_and_return_cities(message):
    responses = []
    cities = []                              # 新增列表收集城市名字
    for tool_call in message.tool_calls:
        if tool_call.function.name == "get_ticket_price":
            arguments = json.loads(tool_call.function.arguments)
            city = arguments.get('destination_city')
            cities.append(city)              # 将本次查询的城市存入列表
            price_details = get_ticket_price(city)
            responses.append({
                "role": "tool",
                "content": price_details,
                "tool_call_id": tool_call.id
            })
    return responses, cities

"""
===== Gradio的3种UI模式说明 =====
gr.Interface:最简单标准UI,输入输出一一对应
gr.ChatInterface:封装好的聊天机器人界面,开箱即用
gr.Blocks:完全自定义UI,可以自由摆放组件、定义回调事件,最灵活
"""

# ====================== Blocks自定义UI:组装完整网页界面 ======================
# 回调函数:接收用户输入消息,清空输入框,把用户消息追加到聊天历史
def put_message_in_chatbot(message, history):
        return "", history + [{"role":"user", "content":message}]

# 使用Blocks上下文管理器,定义UI布局
with gr.Blocks() as ui:
    with gr.Row():                          # Row:横向布局容器,内部组件并排摆放
        chatbot = gr.Chatbot(height=500, type="messages") # 聊天组件,高度500,使用message消息格式
        image_output = gr.Image(height=500, interactive=False) # 图片输出组件,不可手动编辑
    with gr.Row():
        audio_output = gr.Audio(autoplay=True) # 音频播放组件,autoplay=True收到音频自动播放
    with gr.Row():
        message = gr.Textbox(label="Chat with our AI Assistant:") # 用户输入文本框

    # 绑定提交事件:用户输入框按下回车/点提交触发
    # .submit(回调函数,输入组件,输出组件)
    message.submit(put_message_in_chatbot, inputs=[message, chatbot], outputs=[message, chatbot]).then(
        # .then():上一步执行完成之后,再执行chat函数;链式回调
        chat, inputs=chatbot, outputs=[chatbot, audio_output, image_output]
    )

# 启动web服务;inbrowser=True自动打开浏览器;auth设置简单账号密码登录
ui.launch(inbrowser=True, auth=("ed", "bananas"))

 

关键代码逻辑梳理(方便复习)

  1. Function Calling 完整链路
     
    大模型判断需要查票价 → 返回tool_calls → 解析参数 → 调用get_ticket_price数据库查询 → 组装role:tool消息回填上下文 → 再次请求大模型得到最终回答
  2. 多模态触发条件
     
    只有触发get_ticket_price工具拿到城市,才会调用artist()生成图片;每次 AI 回答都会调用talker()生成语音。
  3. Gradio 事件链
     
    用户提交消息 → put_message_in_chatbot把用户消息渲染聊天框 → .then()执行chat()后台 Agent 逻辑 → 返回聊天记录、音频、图片自动渲染页面。
  4. 代码存在重复定义 chat 函数:课程分步演示,后面定义会覆盖前面,实际运行只保留最后一版 chat。

 

 

 

完整

用户输入 → gradio submit事件 → put_message_in_chatbot把用户消息放入聊天历史 → .then()执行chat() Agent逻辑 → 大模型判断是否调用工具 → 调用get_ticket_price查询sqlite票价 → 工具结果回填消息上下文 → 再次请求大模型得到最终回答 → TTS生成语音;如果有城市,artist生成图片 → 返回聊天记录、音频、图片渲染到前端页面

import os
import json
import base64
from io import BytesIO
from dotenv import load_dotenv
from openai import OpenAI
import gradio as gr
from PIL import Image
import sqlite3

# -------------------------- 加载环境变量 & 初始化 --------------------------
load_dotenv(override=True)

openai_api_key = os.getenv("OPENAI_API_KEY")
if openai_api_key:
    print(f"OpenAI API Key exists and begins {openai_api_key[:8]}")
else:
    raise Exception("OPENAI_API_KEY 未设置,请检查 .env 文件")

MODEL = "gpt-4.1-mini"
openai = OpenAI()

DB = "prices.db"

# 系统提示词
system_message = """
You are a helpful assistant for an Airline called FlightAI.
Give short, courteous answers, no more than 1 sentence.
Always be accurate. If you don't know the answer, say so.
"""

# -------------------------- 自动初始化数据库 prices.db --------------------------
def init_db():
    """创建数据表,插入示例城市票价数据"""
    with sqlite3.connect(DB) as conn:
        cur = conn.cursor()
        cur.execute('''
        CREATE TABLE IF NOT EXISTS prices (
            city TEXT PRIMARY KEY,
            price INTEGER
        )
        ''')
        # 示例测试数据
        sample_data = [
            ("paris", 450),
            ("new york city", 620),
            ("london", 480),
            ("tokyo", 890),
            ("sydney", 1200)
        ]
        for city, price in sample_data:
            cur.execute("INSERT OR IGNORE INTO prices(city, price) VALUES (?, ?)", (city, price))
        conn.commit()


def get_ticket_price(city):
    """工具函数:查询往返机票价格"""
    print(f"DATABASE TOOL CALLED: Getting price for {city}", flush=True)
    with sqlite3.connect(DB) as conn:
        cursor = conn.cursor()
        cursor.execute('SELECT price FROM prices WHERE city = ?', (city.lower(),))
        result = cursor.fetchone()
        if result:
            return f"Ticket price to {city} is ${result[0]}"
        else:
            return "No price data available for this city"


# -------------------------- Function Calling 工具定义 --------------------------
price_function = {
    "name": "get_ticket_price",
    "description": "Get the price of a return ticket to the destination city.",
    "parameters": {
        "type": "object",
        "properties": {
            "destination_city": {
                "type": "string",
                "description": "The city that the customer wants to travel to",
            },
        },
        "required": ["destination_city"],
        "additionalProperties": False
    }
}
tools = [{"type": "function", "function": price_function}]


def handle_tool_calls_and_return_cities(message):
    """处理工具调用,同时收集城市名称用于生成图片"""
    responses = []
    cities = []
    for tool_call in message.tool_calls:
        if tool_call.function.name == "get_ticket_price":
            arguments = json.loads(tool_call.function.arguments)
            city = arguments.get("destination_city")
            cities.append(city)
            price_details = get_ticket_price(city)
            responses.append({
                "role": "tool",
                "content": price_details,
                "tool_call_id": tool_call.id
            })
    return responses, cities


# -------------------------- 多模态能力:图片生成、TTS语音 --------------------------
def artist(city):
    """生成城市度假风格图片,返回PIL Image对象"""
    image_response = openai.images.generate(
        model="gpt-image-1-mini",
        prompt=f"An image representing a vacation in {city}, showing tourist spots and everything unique about {city}, in a vibrant pop-art style",
        size="1024x1024",
        n=1,
    )
    image_base64 = image_response.data[0].b64_json
    image_data = base64.b64decode(image_base64)
    return Image.open(BytesIO(image_data))


def talker(message):
    """文本转语音,返回音频bytes"""
    response = openai.audio.speech.create(
        model="gpt-4o-mini-tts",
        voice="onyx",
        input=message
    )
    return response.content


# -------------------------- Agent主聊天逻辑(带循环工具调用) --------------------------
def chat(history):
    history = [{"role": h["role"], "content": h["content"]} for h in history]
    messages = [{"role": "system", "content": system_message}] + history
    response = openai.chat.completions.create(model=MODEL, messages=messages, tools=tools)

    cities = []
    image = None

    # 循环处理工具调用,支持多次连续调用工具
    while response.choices[0].finish_reason == "tool_calls":
        msg = response.choices[0].message
        tool_resp, cities = handle_tool_calls_and_return_cities(msg)
        messages.append(msg)
        messages.extend(tool_resp)
        response = openai.chat.completions.create(model=MODEL, messages=messages, tools=tools)

    reply = response.choices[0].message.content
    history += [{"role": "assistant", "content": reply}]

    voice = talker(reply)
    if cities:
        image = artist(cities[0])

    return history, voice, image


# -------------------------- Gradio Blocks UI布局与事件绑定 --------------------------
def put_message_in_chatbot(message, history):
    # 用户提交消息:清空输入框,把用户消息加入聊天历史
    return "", history + [{"role": "user", "content": message}]


def build_ui():
    with gr.Blocks(title="FlightAI Airline Assistant") as ui:
        with gr.Row():
            chatbot = gr.Chatbot(height=500, type="messages")
            image_output = gr.Image(height=500, interactive=False, label="Destination View")
        with gr.Row():
            audio_output = gr.Audio(autoplay=True, label="Voice Answer")
        with gr.Row():
            message = gr.Textbox(label="Chat with our AI Assistant:")

        # 链式事件:提交消息 → 更新聊天框 → 执行agent chat逻辑
        message.submit(
            fn=put_message_in_chatbot,
            inputs=[message, chatbot],
            outputs=[message, chatbot]
        ).then(
            fn=chat,
            inputs=[chatbot],
            outputs=[chatbot, audio_output, image_output]
        )
    return ui


if __name__ == "__main__":
    init_db()  # 初始化数据库
    ui = build_ui()
    # 启动服务,账号密码 ed / bananas,自动打开浏览器
    ui.launch(inbrowser=True, auth=("ed", "bananas"))

 

 

 

posted @ 2026-08-20 16:09  漫漫长路</>  阅读(2)  评论(0)    收藏  举报