week2 day4 航空 AI 助手项目

 

一、项目总览

本项目基于 OpenAI 大模型 + Gradio 网页界面,从零搭建了航空公司 FlightAI 的智能客服助手,核心突破是引入了工具调用(Function Calling)能力,让 AI 不再只能基于训练知识回答,而是可以调用业务函数查询真实票价数据,并最终通过接入 SQLite 数据库完成工程化升级,实现了从 Demo 到可维护业务系统的演进。
技术栈:OpenAI API / Ollama 本地模型、Gradio、Python、SQLite、python-dotenv

二、项目迭代全流程

1. 基础版:纯对话式客服机器人

先搭建最小可用的对话框架,作为后续功能的载体:
  • 系统提示词设定:明确 AI 角色为 FlightAI 客服,要求回答简短礼貌、不超过 1 句话、保证准确性,不知道答案直接说明,不编造信息。
  • 核心回调函数:实现标准的 chat(message, history) 逻辑 —— 转换 Gradio 历史消息格式 → 拼接系统提示 + 历史对话 + 用户提问 → 调用大模型 → 返回回答。
  • 快速界面搭建:通过 gr.ChatInterface 一行代码生成网页聊天界面,无需前端开发。

2. 进阶版:引入工具调用(Function Calling)

核心原理

大模型本身并不会执行代码,它的能力是识别用户意图后,输出「需要调用的函数名称 + 结构化参数」;由我们的业务代码接收参数、执行函数,再将执行结果以约定格式返回给大模型,最终由大模型把结果整理成自然语言回答用户。

实现步骤

  1. 定义业务工具函数
     
    先实现 get_ticket_price(destination_city) 函数,初始版本用字典硬编码伦敦、巴黎、东京、柏林的票价,输入城市名即可返回对应票价。
  2. 编写工具描述(OpenAI 规范格式)
     
    按照大模型要求的结构,声明工具的名称、功能描述、参数类型、参数说明、必填项,包装成 tools 列表传给大模型。这是模型能否正确调用工具的关键。
  3. 改造 chat 回调函数
    • 调用大模型时传入 tools 参数;
    • 判断响应的 finish_reason == "tool_calls":说明模型判定需要调用工具;
    • 解析模型返回的函数名和参数,执行本地工具函数;
    • 将工具执行结果以 role: "tool" 的格式追加到消息列表;
    • 再次调用大模型,让它基于工具返回的真实数据生成最终回答。

迭代优化

  • 从单次单工具调用升级为支持单次响应内多个工具并行调用,适配用户同时查询多个城市票价的场景;
  • 从单次工具调用升级为 while 循环多轮工具调用:覆盖模型需要连续多次调用工具才能完成回答的场景,保证逻辑闭环。

3. 工程化升级:接入 SQLite 数据库

改造内容

  • 替换硬编码的票价字典,用 SQLite 数据库持久化存储「城市 - 票价」数据;
  • 重写 get_ticket_price 为数据库查询版本,新增 set_ticket_price 函数支持写入 / 更新票价;
  • 增加数据初始化脚本,批量写入基础票价数据,支持后续动态修改。

为什么要连接数据库?

这是从「演示 Demo」走向「可用业务系统」的关键一步,核心原因如下:
  1. 数据持久化:硬编码字典在程序重启后数据就会重置,数据库可以永久保存数据,票价修改后长期生效。
  2. 代码与数据解耦:票价属于业务数据,和代码逻辑分离,后续调整票价、新增城市不需要修改代码、重启服务,运维成本极低。
  3. 可维护性更强:支持标准的增删改查操作,后续批量更新价格、对接后台管理系统都更方便。
  4. 符合生产标准:真实业务场景中,票价、订单、用户信息等业务数据必然存储在数据库中,这是软件工程的标准实践。
  5. 拓展性更好:后续可以很方便地增加舱位、出行日期、折扣等数据维度,也可以关联订单、会员等其他业务表。

为什么选择 SQLite?

它是轻量嵌入式数据库,零配置、无需单独部署服务,单文件即可运行,非常适合小型项目、本地原型开发和演示场景。

三、核心最佳实践

1. 工具调用(Function Calling)最佳实践

  • 描述精准无歧义:函数的功能说明、参数描述要清晰准确,直接决定大模型是否能正确触发工具、传入正确参数。
  • 参数约束严格:明确标注必填参数、参数类型,设置 additionalProperties: false 防止模型传入多余无效参数。
  • 闭环处理多轮调用:使用 while 循环持续判断 finish_reason,覆盖模型连续多次调用工具的场景,避免回答中断。
  • 返回格式标准化:严格按照规范返回包含 role="tool"contenttool_call_id 的结果,确保模型能正确识别并使用工具输出。
  • 参数校验与异常兜底:执行函数前校验参数合法性,工具执行失败、查无数据时返回清晰的提示文本,让模型可以友好告知用户,避免程序崩溃。
  • 添加调用日志:工具执行时打印日志(如城市名、调用结果),便于调试和排查问题。

2. 系统提示词最佳实践

  • 明确角色与边界:清晰定义 AI 的身份、回答风格、长度限制(本例要求不超过 1 句话),保证输出统一。
  • 设定准确性原则:明确要求 “不知道就直说,不编造信息”,尤其客服场景下错误信息会带来业务风险。
  • 业务规则前置:将核心业务要求写入系统提示,从源头规范模型行为。

3. 工程化最佳实践

  • 数据与代码分离:业务数据存入数据库,不硬编码在代码中,便于维护和迭代。
  • 职责分层:数据库操作、工具函数、对话逻辑分层编写,代码结构清晰,后续新增工具更方便。
  • 密钥安全管理:API 密钥通过 .env 文件管理,不硬编码在代码里,避免泄露风险。
  • 兼容多模型:代码预留了 Ollama 本地模型的切换入口,降低部署和成本调整的门槛。

4. 交互体验最佳实践

  • 生产环境建议开启 stream=True 流式输出,搭配 yield 实现打字机效果,大幅降低用户等待感。
  • 保持回答风格与场景匹配:客服场景追求简短、专业、礼貌,避免冗余输出。

四、核心逻辑流程

plaintext
 
 
用户提问 → 组装「系统提示 + 历史对话 + 用户消息」 → 调用大模型(传入 tools)
    ↓
若 finish_reason == tool_calls
    ↓
解析函数名与参数 → 执行本地工具函数 → 工具结果追加到消息列表 → 再次调用大模型
    ↓
循环直到无工具调用 → 返回最终自然语言回答给用户





import os
import json
import sqlite3
from dotenv import load_dotenv
from openai import OpenAI
import gradio as gr

# ===================== 1. 初始化配置 =====================
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:
    print("OpenAI API Key not set")

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

# 可选:切换为本地 Ollama 模型
# MODEL = "llama3.2"
# openai = OpenAI(base_url='http://localhost:11434/v1', api_key='ollama')

# 系统提示词:航空客服角色设定
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.
"""

# ===================== 2. 数据库相关操作 =====================
DB = "prices.db"

# 初始化数据库表
with sqlite3.connect(DB) as conn:
    cursor = conn.cursor()
    cursor.execute('CREATE TABLE IF NOT EXISTS prices (city TEXT PRIMARY KEY, price REAL)')
    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"

def set_ticket_price(city, price):
    """写入/更新城市机票价格"""
    with sqlite3.connect(DB) as conn:
        cursor = conn.cursor()
        cursor.execute(
            'INSERT INTO prices (city, price) VALUES (?, ?) ON CONFLICT(city) DO UPDATE SET price = ?',
            (city.lower(), price, price)
        )
        conn.commit()

# 初始化基础票价数据
initial_ticket_prices = {"london": 799, "paris": 899, "tokyo": 1420, "sydney": 2999}
for city, price in initial_ticket_prices.items():
    set_ticket_price(city, price)

# ===================== 3. 工具定义(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}]

# ===================== 4. 工具调用处理函数 =====================
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)
            city = arguments.get('destination_city')
            price_details = get_ticket_price(city)
            responses.append({
                "role": "tool",
                "content": price_details,
                "tool_call_id": tool_call.id
            })
    return responses

# ===================== 5. 核心聊天回调函数 =====================
def chat(message, history):
    # 转换 Gradio 历史记录为 OpenAI 标准消息格式
    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, tools=tools)
    
    # 循环处理工具调用,直到模型不再需要调用工具
    while response.choices[0].finish_reason == "tool_calls":
        message = response.choices[0].message
        tool_responses = handle_tool_calls(message)
        messages.append(message)
        messages.extend(tool_responses)
        # 再次调用模型,传入工具结果
        response = openai.chat.completions.create(model=MODEL, messages=messages, tools=tools)
    
    return response.choices[0].message.content

# ===================== 6. 启动 Gradio 界面 =====================
gr.ChatInterface(fn=chat, type="messages").launch()

 

功能验证测试

运行代码后,在弹出的网页中可以测试以下场景:
  1. 普通问候:你好 → 模型直接礼貌回复,不调用工具
  2. 单城市票价:去东京的机票多少钱? → 模型自动调用查询工具,返回数据库中的价格
  3. 多城市票价:伦敦和巴黎的票价分别是多少? → 模型并行调用两次工具,统一整理回答
  4. 无数据城市:去北京的机票多少钱? → 工具返回无数据,模型如实告知用户

代码核心亮点

  • 闭环工具调用:while 循环保证多轮工具调用都能被正确执行,逻辑无遗漏
  • 数据持久化:票价存储在 SQLite 数据库中,重启服务数据不丢失
  • 可扩展性强:新增工具只需定义函数 + 补充工具描述 + 在 handle_tool_calls 中增加分支即可
  • 兼容本地模型:预留 Ollama 切换入口,无需修改核心逻辑即可切换大模型
posted @ 2026-08-19 02:56  漫漫长路</>  阅读(7)  评论(0)    收藏  举报