前言
普通大模型能够回答问题,但它不知道店铺数据库中的实时价格、库存和促销政策。如果直接让模型回答"足球现在多少钱",它很可能根据训练数据或语言习惯生成一个看似合理、实际上没有依据的数字。AI Agent要解决的核心问题,就是让大模型在回答前主动使用外部工具获取信息。
本文使用Python从零实现一个电商客服AI Agent,不依赖LangChain、LangGraph等Agent框架,手写完整的ReAct循环。项目支持调用SQLite查询商品、读取促销文件、计算优惠价格,并可在Qwen在线模型与Ollama本地模型之间切换。
本文适合Python初学者、大模型应用开发者,以及希望理解AI Agent底层运行机制的读者。文章将从运行效果、项目结构、ReAct原理、完整代码、调试方法和常见问题几个方面展开。
文章目录
-
- 前言
- 项目效果展示
- 一、项目要解决什么问题
- [二、什么是ReAct Agent](#二、什么是ReAct Agent)
- 三、项目整体运行流程
- 四、项目目录结构
- 五、运行环境与依赖安装
- 六、配置在线Qwen与本地Ollama
- 七、Agent核心代码解析
- 八、三个业务工具的实现
- 九、main.py如何完成ReAct循环
- 十、Ollama客户端实现
- 十一、一次完整问题的执行过程
- 十二、完整运行步骤
- 十三、调试方法
- 十四、常见问题与解决方法
-
- [1. API密钥读取不到](#1. API密钥读取不到)
- [2. Ollama连接失败](#2. Ollama连接失败)
- [3. 找不到模型](#3. 找不到模型)
- [4. 找不到SportsEquipment.db](#4. 找不到SportsEquipment.db)
- [5. 找不到store_promotions.txt](#5. 找不到store_promotions.txt)
- [6. 模型一直不输出Action](#6. 模型一直不输出Action)
- [7. 正则表达式匹配不到Action](#7. 正则表达式匹配不到Action)
- [8. Agent重复调用同一个工具](#8. Agent重复调用同一个工具)
- [9. 促销查询不到](#9. 促销查询不到)
- [10. 价格计算存在安全风险](#10. 价格计算存在安全风险)
- 十五、本项目的优点与局限
- 十六、性能与稳定性优化建议
- 十七、项目扩展方向
- 十八、总结
项目效果展示
环境配置

运行结果

从运行结果可以看到,用户可以连续输入问题,也可以输入"退出"结束程序。项目使用命令行作为交互入口,便于初学者把注意力放在Agent流程本身。
一、项目要解决什么问题
这个项目模拟体育用品店的智能客服,主要完成以下任务:
- 回答普通客服问题。
- 根据商品名称查询SQLite数据库。
- 查询商品是否有库存、价格和品牌信息。
- 从促销文件中查找优惠政策。
- 根据原价和折扣计算最终成交价。
- 保留多轮消息,让模型根据前面的工具结果继续决策。
例如,用户输入:
text
你们有足球吗?现在有什么优惠?买一个最终多少钱?
程序需要依次完成:
text
查询足球商品
→ 得到原价120元和库存信息
→ 查询足球促销政策
→ 得到9折优惠
→ 计算120×0.9
→ 向用户回答最终价格108元
这里真正重要的不是"模型会不会算乘法",而是模型能否判断下一步应该调用哪个工具,并在获得结果后继续完成任务。
二、什么是ReAct Agent
ReAct可以理解为Reasoning与Acting的组合,即让大模型在解决任务时交替进行推理和行动。
本项目采用以下输出约定:

四个阶段分别承担不同职责:
| 阶段 | 负责者 | 作用 |
|---|---|---|
| Thought | 大模型 | 分析当前缺少什么信息 |
| Action | 大模型 | 按约定格式选择工具和参数 |
| Observation | Python程序 | 执行工具并返回真实结果 |
| Answer | 大模型 | 综合已有信息生成最终答复 |
需要注意,大模型本身并不会直接执行Python函数。它只是输出类似下面的字符串:
text
Action: query_by_product_name: 足球
Python主程序解析出工具名query_by_product_name和参数足球,再执行真正的数据库查询函数。
三、项目整体运行流程
项目从用户输入到最终回答的完整路线如下:
text
用户输入问题
↓
main.py把问题交给CustomerServiceAgent
↓
agent.py将系统提示词和对话历史发送给大模型
↓
大模型判断是否需要使用工具
↓
输出Action: 工具名: 参数
↓
main.py使用正则表达式解析Action
↓
执行数据库、促销查询或计算工具
↓
将结果包装为Observation
↓
再次发送给大模型
↓
模型继续调用工具或输出Answer
↓
main.py打印最终客服回复
这个循环最多执行指定次数,避免模型始终调用工具却不给出答案。
四、项目目录结构
text
AI Agent/
│
├── main.py # 程序入口和ReAct调度循环
├── agent.py # Agent提示词、消息历史和模型调用
├── op_llm_client.py # Ollama REST API客户端
├── config.json # 模型名称、接口地址和最大循环次数
├── requirements.txt # Python依赖
├── SportsEquipment.db # 体育用品SQLite数据库
├── store_promotions.txt # 店铺促销政策
│
├── tools/
│ ├── __init__.py
│ ├── query_product_data.py # 查询商品数据库
│ ├── read_promotions.py # 查询促销政策
│ └── calc.py # 计算价格
│
└── architecture/
└── architecture.png # 项目架构图
这样的目录划分把模型调用和业务工具分开。后续增加物流查询、订单查询或售后政策时,可以继续在tools目录增加函数。
五、运行环境与依赖安装
环境要求
- Windows 10或Windows 11
- Python 3.10及以上
- 可访问的Qwen兼容接口,或者已经启动的Ollama服务
- SQLite由Python标准库提供,不需要单独安装
截图中的实际运行环境使用Python 3.13.7。项目依赖如下:
text
python-dotenv
requests==2.32.3
ollama==0.3.3
openai==1.45.0
使用uv创建环境
bash
uv python install 3.13.7
uv python pin 3.13.7
uv sync --locked
如果项目没有锁文件,也可以执行:
bash
uv venv
uv pip install -r requirements.txt
使用pip安装
bash
python -m venv .venv
.venv\Scripts\activate
pip install -r requirements.txt
配置在线模型密钥
旧版项目使用.env文件读取API_KEY:
text
API_KEY=替换为自己的模型服务密钥
不要把真实密钥发布到博客、GitHub或截图中。.env应加入.gitignore:
text
.env
六、配置在线Qwen与本地Ollama
config.json决定使用哪一种模型:
json
{
"ollama": {
"use_model": false,
"model_name": "llama3.1:8b",
"temperature": 1.0,
"max_iterations": 20
},
"openai": {
"use_model": true,
"model_name": "qwen-plus",
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"temperature": 1.0,
"max_iterations": 20
}
}
参数说明
| 参数 | 类型 | 作用 | 建议 |
|---|---|---|---|
use_model |
bool | 是否选择该模型路线 | 两条路线只启用一条 |
model_name |
str | 模型名称 | 必须与服务端已有模型一致 |
base_url |
str | OpenAI兼容接口地址 | 按服务商文档填写 |
temperature |
float | 控制输出随机性 | 客服场景建议0.1~0.7 |
max_iterations |
int | 单个问题最大Agent循环次数 | 建议5~20 |
当前代码的get_client()优先判断在线模型开关。如果openai.use_model为false,就创建Ollama客户端。因此配置时不要让两个开关同时处于含义不明确的状态。
七、Agent核心代码解析
功能说明
CustomerServiceAgent负责保存系统提示词、维护消息历史和调用大模型。它不直接执行商品工具,工具调度由main.py完成。
核心代码
python
from op_llm_client import OllamaClient
class CustomerServiceAgent:
def __init__(self, client, config):
self.client = client
self.config = config
self.messages = []
self.system_prompt = """
You are an intelligent customer service assistant for an e-commerce platform.
If the question does not involve a specific product, answer directly using:
Answer: [Your answer here]
If product information is involved, run in a loop of Thought, Action and Observation.
Available tools:
1. query_by_product_name: Query products by product name.
2. read_store_promotions: Read promotions related to a product.
3. calculate: Calculate the final transaction price.
When using an Action, always format it as:
Action: tool_name: argument1, argument2, ...
When you have a final answer, output:
Answer: [Your answer here]
You must reply to the final result in Chinese.
""".strip()
self.messages.append({"role": "system", "content": self.system_prompt})
def __call__(self, message):
self.messages.append({"role": "user", "content": message})
response = self.execute()
if not isinstance(response, str):
raise TypeError(
f"Expected string response from execute, got {type(response)}"
)
self.messages.append({"role": "assistant", "content": response})
return response
def execute(self):
if isinstance(self.client, OllamaClient):
completion = self.client.chat_completions_create(
model=self.config["ollama"]["model_name"],
messages=self.messages,
temperature=self.config["ollama"]["temperature"]
)
if isinstance(completion, dict) and "message" in completion:
return completion["message"].get("content", "")
if isinstance(completion, str):
return completion
raise ValueError(f"Unexpected Ollama response: {completion}")
completion = self.client.chat.completions.create(
model=self.config["openai"]["model_name"],
messages=self.messages,
)
response = completion.choices[0].message.content
if response is not None:
return response
return "当前没有正常生成回复,请重新思考当前问题并再次尝试"
参数与返回值说明
client 是模型客户端,可能是OpenAI兼容客户端,也可能是自定义OllamaClient。
config 是从config.json读取的字典,保存模型名称和相关参数。
message 是当前一轮传给Agent的字符串。第一次通常是用户原始问题,后续可能是工具执行结果组成的Observation。
call()返回值 是模型生成的字符串,内容可能是Action:或Answer:。
为什么实现__call__方法
定义__call__()之后,对象可以像函数一样调用:
python
agent = CustomerServiceAgent(client, config)
result = agent("足球多少钱?")
它等价于:
python
result = agent.__call__("足球多少钱?")
这样可以让主循环保持简洁,同时把"添加用户消息、调用模型、保存模型回复"封装在类内部。
调试提示
建议在下面三个位置打断点:
self.messages.append()之后,观察消息历史是否正确。response = self.execute()之后,观察模型原始输出。completion.choices[0].message.content处,确认接口返回结构。
重点观察:
text
self.messages
response
completion
type(self.client)
八、三个业务工具的实现
商品数据库查询工具
python
import sqlite3
def query_by_product_name(product_name):
with sqlite3.connect("SportsEquipment.db") as connection:
cursor = connection.cursor()
cursor.execute(
"SELECT * FROM products WHERE product_name LIKE ?",
("%" + product_name + "%",)
)
rows = cursor.fetchall()
return rows
这里使用LIKE完成模糊查询。例如参数为"球",可能匹配足球、篮球和乒乓球拍套装。
SQL参数没有通过字符串拼接直接写入语句,而是使用?占位符,这样可以降低SQL注入风险,并避免商品名中的特殊字符破坏SQL语句。
返回值是列表,列表中的每条记录都是元组:
text
[
('001', '足球', '高品质职业比赛用球', ..., 120.0, 50)
]
促销政策读取工具
python
def read_store_promotions(product_name):
file_path = "store_promotions.txt"
try:
with open(file_path, "r", encoding="utf-8") as file:
promotions_content = file.readlines()
filtered_content = [
line for line in promotions_content
if product_name in line
]
if filtered_content:
return "".join(filtered_content)
return "没有找到关于该产品的优惠政策。"
except FileNotFoundError:
return "优惠政策文档未找到,请检查文件路径是否正确。"
except Exception as error:
return f"读取优惠政策文档时发生错误: {error}"
该函数逐行读取文本,只返回包含商品名称的行。实现简单、容易理解,适合少量促销数据;如果优惠记录很多,更合适的方案是数据库查询或全文检索。
价格计算工具
python
def calculate(expression):
return eval(expression)
模型可以输出:
text
Action: calculate: 120 * 0.9
工具返回:
text
108.0
这里必须说明:eval()会执行输入字符串中的Python表达式,存在明显安全风险,只适合教学演示,不能直接用于不可信输入的生产系统。更安全的方案是限制运算符,或者使用Decimal根据结构化折扣规则计算。
商品和促销数据初始化
为了让读者在没有原始数据库文件时也能复现项目,可以新建initialize_data.py:
python
import sqlite3
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent
DATABASE_PATH = PROJECT_ROOT / "SportsEquipment.db"
PROMOTIONS_PATH = PROJECT_ROOT / "store_promotions.txt"
products = [
("001", "足球", "高品质职业比赛用球,符合国际标准", "圆形,直径22 cm", "职业比赛、学校体育课", "耐克", 120, 50),
("002", "羽毛球拍", "轻量级,适合初中级选手", "碳纤维材质,重量85 g", "业余比赛、家庭娱乐", "尤尼克斯", 300, 30),
("003", "篮球", "室内外可用,耐磨耐用", "皮质,标准7号球", "学校、社区运动场", "斯伯丁", 200, 40),
("004", "跑步鞋", "适合长距离跑步,舒适透气", "多种尺码,透气网布", "长跑、日常训练", "阿迪达斯", 500, 20),
("005", "瑜伽垫", "防滑材料,易于携带和清洗", "长180cm,宽60cm,厚5mm", "瑜伽、普拉提", "曼达卡", 150, 25),
("006", "速干运动衫", "吸汗快干,适合户外运动", "S/M/L/XL,多色可选", "运动、徒步、旅游", "诺斯脸", 180, 60),
("007", "电子计步器", "精确计步,带心率监测功能", "可充电,续航7天", "日常健康管理、运动", "Fitbit", 250, 15),
("008", "乒乓球拍套装", "包括两只拍子和三个球", "标准尺寸,拍面防滑", "家庭、社区", "双鱼", 160, 35),
("009", "健身手套", "抗滑耐磨,保护手部", "多种尺码,通风设计", "健身房、户外运动", "Under Armour", 120, 50),
("010", "膝盖护具", "减少运动伤害,提供支撑和保护", "弹性织物,可调节紧度", "篮球、足球及其他运动", "麦克戴维", 220, 40),
]
promotions = """店铺优惠政策:
1. 足球 - 购买足球即可享受9折优惠。
2. 羽毛球拍 - 任意购买羽毛球拍两支以上,享8折优惠。
3. 篮球 - 单笔订单满300元,篮球半价。
4. 跑步鞋 - 第一次购买跑步鞋可享受满500元减100元优惠。
5. 瑜伽垫 - 购买瑜伽垫赠送价值50元的瑜伽教程视频。
6. 速干运动衫 - 买三送一,赠送最低价商品。
7. 电子计步器 - 赠送配套手机APP永久会员资格。
8. 乒乓球拍套装 - 每套95折。
9. 健身手套 - 满200元包邮。
10. 膝盖护具 - 每件配赠运动护膝一个。
注意:所有优惠活动不可与其他优惠同享,详情以下单时为准。
"""
with sqlite3.connect(DATABASE_PATH) as connection:
connection.execute("DROP TABLE IF EXISTS products")
connection.execute(
"""
CREATE TABLE products (
product_id TEXT PRIMARY KEY,
product_name TEXT NOT NULL,
description TEXT NOT NULL,
specifications TEXT NOT NULL,
usage TEXT NOT NULL,
brand TEXT NOT NULL,
price REAL NOT NULL,
stock_quantity INTEGER NOT NULL
)
"""
)
connection.executemany(
"INSERT INTO products VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
products
)
PROMOTIONS_PATH.write_text(promotions, encoding="utf-8")
print(f"数据库已创建:{DATABASE_PATH}")
print(f"促销文件已创建:{PROMOTIONS_PATH}")
初始化数据只需要运行一次:
bash
python initialize_data.py
这个脚本会在项目根目录生成SportsEquipment.db和store_promotions.txt。脚本包含DROP TABLE,会覆盖同名商品表,因此不要对已经包含真实业务数据的数据库直接运行。
九、main.py如何完成ReAct循环
功能说明
main.py是旧版项目最关键的文件,负责创建模型、注册工具、接收用户输入、解析Action、执行工具以及判断最终答案。
完整主流程代码
python
import json
import os
import re
from dotenv import load_dotenv
from openai import OpenAI
from agent import CustomerServiceAgent
from op_llm_client import OllamaClient
from tools.calc import calculate
from tools.query_product_data import query_by_product_name
from tools.read_promotions import read_store_promotions
load_dotenv()
def load_config():
with open("config.json", "r", encoding="utf-8") as file:
return json.load(file)
def get_client(config):
if config["openai"].get("use_model", True):
return OpenAI(
api_key=os.environ.get("API_KEY"),
base_url=config["openai"]["base_url"]
)
return OllamaClient()
def get_max_iterations(config):
if config["ollama"]["use_model"]:
return config["ollama"]["max_iterations"]
if config["openai"]["use_model"]:
return config["openai"]["max_iterations"]
return 10
def main():
config = load_config()
client = get_client(config)
agent = CustomerServiceAgent(client, config)
tools = {
"query_by_product_name": query_by_product_name,
"read_store_promotions": read_store_promotions,
"calculate": calculate,
}
while True:
query = input("输入您的问题或输入 '退出' 来结束: ")
if query.lower() in {"退出", "exit"}:
break
if not query.strip() or query.startswith("("):
continue
iteration = 0
max_iterations = get_max_iterations(config)
while iteration < max_iterations:
try:
result = agent(query)
action_pattern = re.compile(r"^Action: (\w+): (.*)$")
actions = [
action_pattern.match(line)
for line in result.split("\n")
if action_pattern.match(line)
]
if actions:
action_parts = (
result.split("Action:", 1)[1]
.strip()
.split(": ", 1)
)
tool_name = action_parts[0]
tool_args = action_parts[1] if len(action_parts) > 1 else ""
if tool_name in tools:
try:
observation = tools[tool_name](tool_args)
query = f"Observation: {observation}"
except Exception as error:
query = (
"Observation: Error occurred while executing "
f"the tool: {error}"
)
else:
query = f"Observation: Tool '{tool_name}' not found"
elif "Answer:" in result:
answer = result.split("Answer:", 1)[1].strip()
print(f"客服回复:{answer}")
break
else:
query = (
"Observation: No valid action or answer found. "
"Please provide a clear action or answer."
)
except Exception as error:
print(f"处理问题时发生错误:{error}")
break
iteration += 1
if iteration == max_iterations:
print("达到最大迭代次数,仍未获得最终答案。")
if __name__ == "__main__":
main()
正则表达式解析
python
action_pattern = re.compile(r"^Action: (\w+): (.*)$")
该表达式要求模型输出满足:
text
Action: 工具名: 参数
其中:
^表示必须从行首开始。Action:是固定前缀。(\w+)捕获工具名称。(.*)捕获工具参数。$表示匹配到行尾结束。
如果模型使用中文冒号、增加多余文字或改变格式,正则表达式可能无法识别。这也是文本式ReAct Agent的主要局限之一。
工具注册表
python
tools = {
"query_by_product_name": query_by_product_name,
"read_store_promotions": read_store_promotions,
"calculate": calculate,
}
字典的键必须和Prompt中约定的工具名称一致,值是真正可执行的Python函数。
模型输出:
text
Action: query_by_product_name: 足球
程序执行:
python
tools["query_by_product_name"]("足球")
Observation为什么再次作为用户消息加入
工具执行结果会被包装为:
python
query = f"Observation: {observation}"
下一轮调用agent(query)时,结果被追加到消息历史中。模型因此可以看到前一轮工具结果,并决定继续查询优惠、计算价格,或者给出最终回答。
十、Ollama客户端实现
python
import json
import requests
class OllamaClient:
def __init__(self, base_url="http://127.0.0.1:11434"):
self.base_url = base_url
def chat_completions_create(self, model, messages, temperature=0.7):
url = f"{self.base_url}/api/generate"
payload = {
"model": model,
"prompt": self._format_messages(messages),
"stream": False,
"temperature": temperature
}
response = requests.post(
url=url,
headers={"Content-Type": "application/json"},
data=json.dumps(payload),
timeout=120
)
response.raise_for_status()
return response.json()["response"]
def _format_messages(self, messages):
formatted = []
for message in messages:
if message["role"] == "system":
formatted.append(f"System: {message['content']}")
elif message["role"] == "user":
formatted.append(f"Human: {message['content']}")
elif message["role"] == "assistant":
formatted.append(f"Assistant: {message['content']}")
return "\n".join(formatted)
Ollama的/api/generate接口接收的是完整Prompt,因此需要把messages列表转换为System、Human、Assistant组成的文本。
如果本地服务没有启动,通常会出现连接失败。运行Agent前应先确认:
bash
ollama list
ollama run llama3.1:8b
十一、一次完整问题的执行过程
假设用户输入:
text
你们有足球吗?有什么优惠?购买一个最终多少钱?
第一轮模型输出:
text
Thought: 我需要先查询足球商品信息。
Action: query_by_product_name: 足球
Python查询数据库后返回:
text
Observation: 商品001,足球,品牌耐克,价格120元,库存50件。
第二轮模型输出:
text
Thought: 已经获得商品价格,还需要查询优惠政策。
Action: read_store_promotions: 足球
工具返回:
text
Observation: 购买足球即可享受9折优惠。
第三轮模型输出:
text
Thought: 需要计算9折后的价格。
Action: calculate: 120 * 0.9
计算结果:
text
Observation: 108.0
第四轮模型生成最终回答:
text
Answer: 本店有足球,原价120元,目前享受9折优惠,购买一个最终价格为108元。
这就是完整的ReAct闭环。每次模型只决定下一步,Python负责执行真实工具。
十二、完整运行步骤
- 进入项目目录。
bash
cd "D:\Jupyter_Projects\PythonProject\大模型学习\AI Agent"
- 安装依赖。
bash
uv sync --locked
或者:
bash
pip install -r requirements.txt
-
配置在线模型密钥,或者启动Ollama。
-
检查数据库和促销文件是否存在。
bash
Test-Path ".\SportsEquipment.db"
Test-Path ".\store_promotions.txt"
- 运行程序。
bash
uv run python main.py
或者:
bash
python main.py
- 输入问题进行测试。
text
你们有足球吗?有什么优惠?买一个多少钱?
- 输入"退出"结束程序。
十三、调试方法
推荐断点位置
在PyCharm或VS Code中,建议按顺序设置断点:
query = input(...)之后,确认用户问题。result = agent(query)之后,查看模型原始输出。actions = [...]之后,检查正则是否匹配成功。tool_name = action_parts[0]之后,查看工具名和参数。observation = tools[tool_name](tool_args)之后,查看工具结果。completion.choices[0].message.content处,查看接口回复。
重点观察变量
text
query
result
actions
tool_name
tool_args
observation
iteration
agent.messages
调试时最重要的是判断问题位于哪一层:
text
模型没有按格式输出
还是
正则解析失败
还是
工具执行失败
还是
工具结果没有正确返回模型
十四、常见问题与解决方法
1. API密钥读取不到
问题表现:接口返回认证失败,或者API_KEY为None。
原因:.env不在程序当前运行目录,变量名不一致,或者没有执行load_dotenv()。
解决方法:检查.env内容和当前目录,不要在变量名两侧加入多余空格。
2. Ollama连接失败
问题表现:出现ConnectionError或无法访问11434端口。
原因:Ollama服务没有启动。
解决方法:先执行ollama list确认环境,再启动所需模型。
3. 找不到模型
问题表现:接口返回模型不存在。
原因:config.json中的模型名称与服务端不一致。
解决方法:在线模型检查服务商模型列表;Ollama执行ollama list查看本地模型名称。
4. 找不到SportsEquipment.db
问题表现:SQL提示没有products表,或者产生新的空数据库。
原因:代码使用相对路径,启动目录不正确。
解决方法:进入AI Agent根目录后运行main.py,或者改用基于__file__的绝对路径。
5. 找不到store_promotions.txt
问题表现:返回"优惠政策文档未找到"。
原因:运行目录不正确或文件被移动。
解决方法:检查文件是否与main.py位于同一运行目录。
6. 模型一直不输出Action
问题表现:模型直接编造商品信息,或者输出自由格式文本。
原因:模型对System Prompt遵循能力较弱,或者Prompt中的工具格式不够明确。
解决方法:降低温度,补充正确示例,并在Prompt中重复强调Action格式。
7. 正则表达式匹配不到Action
问题表现:模型看似调用工具,但actions为空。
原因:模型使用中文冒号、前置空格或其他格式。
解决方法:打印repr(result)查看真实字符;教学项目可以调整正则,工程项目更建议使用原生Function Calling。
8. Agent重复调用同一个工具
问题表现:不断查询同一商品,直到达到最大循环次数。
原因:模型没有理解Observation,或者消息历史中的格式不清楚。
解决方法:检查agent.messages,确认工具结果已进入消息历史;适当增加停止规则和工具调用记录。
9. 促销查询不到
问题表现:商品存在,但返回没有优惠政策。
原因:文本匹配要求商品名称完全出现在促销行中,模型可能传入了额外描述。
解决方法:先让模型使用数据库返回的标准商品名称,再查询促销。
10. 价格计算存在安全风险
问题表现:calculate()可以执行超出四则运算的Python表达式。
原因:使用了eval()。
解决方法:教学演示后应改成受限表达式解析,或使用Decimal和结构化优惠规则。
十五、本项目的优点与局限
优点
- 不依赖Agent框架,容易理解底层循环。
- 商品、促销、计算三个工具职责清晰。
- 同时支持在线模型和Ollama。
- 能直观看到Thought、Action、Observation的转换过程。
- 适合AI Agent入门学习和调试。
局限
- 工具调用依赖模型严格输出文本格式。
- 正则表达式解析不够稳定。
eval()不适合生产环境。- 商品查询仅依赖名称模糊匹配。
- 没有对模型最终回答进行证据校验。
- 对话历史不断增长,没有会话长度控制。
- 相对路径依赖程序启动目录。
这些局限并不影响它作为教学项目的价值。相反,正因为所有步骤都显式写在代码里,读者才更容易理解Agent框架在背后做了什么。
十六、性能与稳定性优化建议
- 将模型温度降低到0.1~0.5,提高工具格式稳定性。
- 只保留最近若干轮消息,控制上下文长度和费用。
- 为HTTP请求增加合理的超时时间和有限重试。
- 将相对路径改为基于
Path(__file__)的绝对路径。 - 给数据库查询建立必要索引。
- 对工具参数进行类型和范围验证。
- 将工具结果统一转换为JSON,减少模型理解歧义。
- 使用日志记录每轮模型输出、工具名称和耗时。
十七、项目扩展方向
这个基础版本可以继续扩展:
- 使用模型原生Function Calling替代Action正则解析。
- 使用Embedding和RAG支持自然语言商品推荐。
- 使用SQL对价格、库存和优惠进行二次验证。
- 增加模型回答证据校验,降低商品幻觉。
- 接入订单、物流和售后系统。
- 使用FastAPI封装客服接口。
- 增加Web或PyQt聊天界面。
- 按用户保存独立会话历史。
- 增加人工客服转接机制。
环封装到agent.answer()内部,并加入语义检索、SQL复核和回答校验。
后续文章将从旧版文本ReAct逐步升级到原生Function Calling,并进一步实现商品RAG检索和防幻觉机制。
十八、总结
本文使用Python实现了一个不依赖Agent框架的电商客服AI Agent,完整分析了System Prompt、消息历史、ReAct循环、工具注册、Action正则解析、SQLite商品查询、促销文件读取和优惠价格计算。
通过这个项目可以看到,AI Agent并不是一个神秘的新模型。它的基本结构是"大模型负责决策,Python负责执行工具,工具结果再返回模型"。ReAct循环让模型能够把复杂任务拆成多个步骤,但文本协议也带来了格式不稳定、参数缺少约束和最终答案难以验证等问题。
对于初学者来说,先手写一次ReAct流程,再学习Function Calling、RAG或Agent框架,会更容易理解这些高级工具解决了什么问题。下一篇将继续逐行分析旧版项目的代码调用关系和调试方法,为后续重构做好准备。