← 学习路线
AI 学习

MySQL、Memory 到 Agent 整合 · 第 35—53 课完整笔记

25 个章节串联数据库、消息恢复、工具调用与 Agent Loop,保留当时的代码、错误和验证记录。

康雨 · 发布 · 约 103 分钟

阅读基础:按章节逐步阅读;示例环境见正文

读完可以:查阅原始解释、完整代码和排错上下文

完整笔记:约 51.2 千字与代码字符 · 208 个代码/流程块。可按目录分次查阅;内容来自现有笔记,不补写未记录的经历。

本文目录

完整学习档案:保留原有章节、代码、失败示例、排错过程和练习。代码反映记录时的环境,不代表每段都是最终版本,也不代表本次发布重新运行了全部示例。请在练习项目中使用自己的环境配置,勿填入生产密钥。

整理日期:2026-09-11
学习范围:承接上一次 RAM Memory 笔记,从第 35 课 MySQL 持久化 Chat Memory 开始,到第 53 课 Chat History 与 Agent Trace 完成。
本次停止位置:带持久化聊天历史、工具调用和 Agent Loop 的 FastAPI 后端已跑通。不包含第 54 课 System Prompt 的教学与代码改造。
依据:原对话《分支 · Python基础学习指南》的课程讲解、本人练习回答、终端记录,以及本次重新查看的最后三张实操截图。

阅读说明

这份笔记按“遇到了什么问题 → 为什么这样设计 → 代码怎么工作 → 数据怎么流动 → 实际验证 → 容易混淆的地方”整理,作为以后复习和重建项目的材料。

代码分为两类:

  • 课堂阶段代码:保留当时的实现路线和函数接口,帮助对照学习过程。
  • 整理补充:为防止复习时误解而补充的边界、解释或局部改进,不代表本人当时已经完成了这些升级。

文中“已验证”指原对话中已有结果或截图支持;“复现步骤”“预期结果”是以后重新检查的方法。本次整理没有重新连接个人 MySQL,也没有重新调用 Qwen。Python 3.8、MySQL 5.5.11 是原对话记录的学习环境,不是当前重新探测或安装的版本建议。

最终整合示例统一使用请求字段 message。前面的持久化 Chatbot 阶段使用过 question,两者的变化会单独说明,复制时不要混用。


1. 学习路径与架构演进

1.1 上一次笔记结束在哪里

上一次已经完成:

text
Python 基础
→ HTTP / API
→ FastAPI
→ 调用 Qwen
→ messages 多轮对话
→ conversation_id 区分会话
→ memory.py 用 Python dict 保存聊天历史

那时的系统已经能够在同一段会话中回答“我叫什么名字”,但是历史主要保存在 Python 进程的 RAM 中。服务器进程结束以后,这份内存数据不会自动恢复。

这一阶段的主线就是连续解决两个问题:

  1. 怎样让历史在程序重启后还能取回来?
  2. 怎样让模型根据任务调用工具,并在拿到结果后继续处理?

最终把这两条能力接入同一个 HTTP 接口。

1.2 课程与能力对应关系

课程 本阶段的核心问题 形成的能力
第 35 课 为什么需要数据库? 区分临时状态、持久化历史与长期记忆
第 36 课 聊天记录怎么存成表? 创建 agent_demo、conversations、messages
第 37 课 Python 怎样连接数据库? PyMySQL、环境变量、db.py
第 38 课 数据库操作对象分别是什么? connection / cursor / execute / fetch
第 39 课 SQL 结果怎样变成模型上下文? MySQL 版 get_messages()
第 40 课 怎样写消息并创建新会话? add_message()、ensure_conversation()
第 41 课 怎样接回 HTTP 服务? FastAPI + MySQL 持久化 Chat Memory
第 42 课 Chatbot 如何迈向 Agent? Reason / Act / Observe、Loop 与 Flow
第 43 课 怎样准备真实可执行的能力? tools.py 与 calculator()
第 44 课 怎样告诉模型有哪些工具? Tool Schema、Function Calling
第 45 课 怎样执行模型提出的调用? 解析 tool_calls 与 JSON arguments
第 46 课 工具执行后怎样继续? Tool Result 回传,形成完整调用闭环
第 47 课 任务需要不止一次工具怎么办? while Agent Loop、for 多调用
第 48 课 工具越来越多怎么管理? Tool Registry、函数对象、**arguments
第 49 课 循环不停或工具报错怎么办? max_steps、未知工具处理、异常转 Observation
第 50 课 测试脚本过大怎么办? 明确模块职责与 run_agent() 接口
第 51 课 拆完模块是否仍然能跑? 修复缩进,完成模块化运行验证
第 52 课 怎样把 Agent 接回已有服务? Agent + FastAPI + MySQL Memory
第 53 课 哪些是历史,哪些是执行轨迹? 区分 Chat History、Runtime Context、Agent Trace

1.3 架构不是一下子出现的

阶段 A:RAM 中的多轮 Chatbot

text
Client → FastAPI → memory.py / dict → model.py → Qwen
                    ↑                         ↓
                    └──── 保存新对话 ─────────┘

阶段 B:持久化 Chatbot

text
Client → FastAPI
           ├─ memory.py → MySQL → 读取历史
           ├─ model.py → Qwen → 得到回答
           └─ memory.py → MySQL → 保存本轮 user + assistant

这里变化的是 Memory 的底层实现,读写函数的接口基本不变。

阶段 C:独立的工具调用实验

text
tool_call_test.py
→ Qwen 读取工具说明
→ 产生 tool_calls
→ Python 执行 calculator
→ 把结果回传 Qwen
→ 最终回答

先单独验证工具链,是为了避免同时排查 HTTP、数据库、网络和工具代码。

阶段 D:独立 Agent Runtime

text
tool_call_test.py
→ run_agent(messages)
→ 循环调用 Qwen
→ 查询 Tool Registry
→ 执行工具
→ 添加 Observation
→ 继续循环 / 返回最终答案

阶段 E:当前已经完成的整合

text
Client / Swagger
       ↓
main.py:接收 conversation_id + message
       ↓
memory.py → MySQL:读取历史
       ↓
历史 + 当前 user message
       ↓
agent.py:run_agent()
       ├─ Qwen:决定下一步
       ├─ tools.py:Schema + Registry + calculator
       └─ 工具结果 → 上下文 → 再次调用 Qwen
       ↓
最终 answer
       ↓
memory.py → MySQL:只保存当前 user + 最终 assistant
       ↓
FastAPI 返回 JSON

在第 52 课的实际接法中,main.py 负责协调 Memory 与 Agent;agent.py 并不直接查询 MySQL。这一点有助于准确理解模块关系。


2. 从 RAM Memory 到 MySQL 持久化

2.1 原来的 memory 到底是什么

python
memory = {
    "abc123": [
        {"role": "user", "content": "我的名字叫小明"},
        {"role": "assistant", "content": "好的,我记住了"}
    ]
}

逐层看:

  • memory 是一个 Python 字典。
  • "abc123" 是用于查找某段会话的 key。
  • 对应的 list 保存这一段会话中的多条消息,并保留顺序。
  • list 中每一个 dict 描述一条消息。
  • role 表示消息来自谁,content 表示消息内容。

**一条消息不等于一轮对话。**一次用户提问和一次助手回答,通常是两个 dict。

2.2 为什么重启后没有了

text
启动 Uvicorn
→ 加载 Python 模块
→ 创建 memory 字典
→ 对话不断追加到字典
→ 停止这个 Python 进程
→ 这份进程内存不再保留
→ 再启动时重新创建初始 memory

保存到变量,只说明“当前进程能使用它”,并不等于“已经写入可恢复的持久化存储”。

在开发时,自动重载也可能重启工作进程,因此不能把 RAM 字典当成长期保存聊天历史的依据。

2.3 MySQL 替换的是哪一层

替换前:

text
get_messages → dict.get
add_message  → list.append

替换后:

text
get_messages → SELECT
add_message  → INSERT + commit

外部代码仍然可以调用:

python
history = get_messages(conversation_id)
add_message(conversation_id, "user", question)

这就是拆分 memory.py 的价值:调用者知道“要读写什么”,不必知道“底层如何存储”。

2.4 数据库没有让模型永久学会这些消息

持久化后的实际路径:

text
MySQL 保存历史
→ Python 根据 conversation_id 查询
→ 把结果整理成 messages
→ 本次请求把 messages 交给 Qwen
→ Qwen 根据本次收到的上下文回答

这里没有发生模型训练,也没有因为插入 SQL 就修改模型参数。

“模型记得我叫什么名字”在当前应用中的含义是:应用再次给模型提供了包含名字的历史。

2.5 不要把“关闭客户端”和“停止数据库服务”混为一谈

  • 关闭 MySQL 命令行窗口:关闭一个客户端。
  • 关闭 FastAPI/Uvicorn:停止应用服务进程。
  • 停止 MySQL Server:数据库暂时不能连接。
  • 数据库中的已提交数据:正常情况下仍然保留,之后可重新读取。

这些不是同一件事。MySQL Server 才是处理数据库请求的服务;mysql 命令行和 Navicat 都只是访问它的客户端。


3. Chat History 与 Long-term Memory

3.1 最重要的纠正:不是按“存在哪里”区分

本人曾把:

text
Chat History → RAM
Long-term Memory → 数据库

作为区分。这个理解需要修正。

本课程语境下,两者主要按内容和用途区分:

维度 Chat History Long-term Memory
保存什么 一段会话中实际说过什么 提炼出的长期有用信息
典型形式 按顺序排列的 user / assistant 原始消息 用户偏好、长期项目、稳定事实等
主要用途 继续当前会话、解决上下文指代 跨多轮或跨会话提供相关背景
是否能存 MySQL 能 也能
是否必须放 RAM 否 否
本阶段是否已实现 已实现持久化 Chat History 尚未建立提炼、更新与检索机制

这里采用课程中的术语区分;不同框架对 Memory 的命名范围可能不同。复习时先看保存内容和使用方式,再看名字。

3.2 对比例子

Chat History:

text
user:我现在在学习 FastAPI。
assistant:可以先从路由和请求体开始。
user:我想以后做 Agent。
assistant:接下来可以学习工具调用。

长期信息可能被整理为:

text
用户正在学习 AI Agent 开发。
用户希望通过实践理解 Python 与后端。

后者需要判断哪些信息值得保留、如何更新、什么时候取出,不是简单把全部聊天记录换一个表名。

3.3 持久化与记忆内容是两个维度

text
存储维度:
临时 RAM / 持久化数据库

信息维度:
聊天原文 / 提炼信息 / 执行轨迹

所以“持久化 Chat History”这个说法是准确的。长期保存了一份聊天原文,不代表已经实现完整的 Long-term Memory 系统。


4. conversations / messages 表设计

4.1 为什么需要两张表

conversations 回答:

系统里有哪些会话?

messages 回答:

每一段会话里,谁在什么时候说了什么?

例如:

text
conversations
└─ abc123
   ├─ user:我的名字叫岭西
   ├─ assistant:你好
   ├─ user:我叫什么名字?
   └─ assistant:你叫岭西

如果把会话信息重复塞进每一条消息,之后增加会话标题、所属用户等字段时,会产生大量重复数据和维护问题。

4.2 一对多关系应该由“多”的一方记录归属

课堂概念扩展:

text
一个 User
→ 多个 Conversation
→ 每个 Conversation 又包含多个 Message

通常是:

text
conversations.user_id → users.id
messages.conversation_id → conversations.conversation_id

并不是在一行 users 里不断增加 conversation1、conversation2、conversation3,也不是在一行 conversations 里列出所有 message_id。

让“多”的一方记录自己属于谁。

本阶段实际只创建了 conversations 和 messages;users 表及用户鉴权属于概念讨论,不能写成已经实现。

4.3 conversations 字段

sql
CREATE TABLE conversations (
    id INT AUTO_INCREMENT PRIMARY KEY,
    conversation_id VARCHAR(100) NOT NULL UNIQUE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
字段或约束 作用
id 数据库内部使用的行标识
INT 整数类型
AUTO_INCREMENT 插入新行时自动生成递增值
PRIMARY KEY 唯一标识这一行
conversation_id 应用层用来定位会话的字符串,例如 abc123
VARCHAR(100) 最大长度为 100 个字符的变长字符串字段
NOT NULL 不允许 SQL NULL;不等于自动禁止空字符串
UNIQUE 同一会话标识不能重复建两行
created_at 保存记录创建时间

id 和 conversation_id 不要混淆:

text
id = 1
conversation_id = abc123

它们描述的是同一条会话记录的不同标识。当前 HTTP 请求和查询使用的是 conversation_id。

4.4 messages 字段

sql
CREATE TABLE messages (
    id INT AUTO_INCREMENT PRIMARY KEY,
    conversation_id VARCHAR(100) NOT NULL,
    role VARCHAR(20) NOT NULL,
    content TEXT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
字段 作用
id 一条消息的标识,课堂通过它排序
conversation_id 这条消息属于哪个会话
role user 或 assistant;当前持久化策略只写这两种
content 消息正文
created_at 插入时间

content 使用 TEXT 是因为聊天内容可能较长。但 TEXT 也有容量限制,不能理解成无限长字段。

4.5 概念上的关联不等于已经建立外键约束

课堂最小建表代码没有声明 FOREIGN KEY。

因此:

  • messages.conversation_id 在业务逻辑上关联会话。
  • 数据库当时不会因为这个名字,就自动校验会话一定存在。
  • ensure_conversation() 是当前应用层维持关系的一部分。

UNIQUE 也不等于外键:前者解决“本表中不能重复”,后者解决“关联值必须符合另一张表的约束”。

4.6 为什么查询必须按顺序

sql
SELECT role, content
FROM messages
WHERE conversation_id = 'abc123'
ORDER BY id ASC;

WHERE 决定“取哪一段会话”,ORDER BY 决定“以什么顺序取”。

模型需要先看到前面的提问和回答,再看到后面的追问。不要依赖没有显式排序时数据库碰巧返回的顺序。

当前课堂单请求顺序写入时,递增 id 足以用于复习示例;它不代表已经解决同一会话多请求并发时的业务顺序问题。


5. 进入 MySQL、建库建表与手工读写

5.1 先辨认自己在哪里输入命令

位置 提示符示例 应输入什么
Windows CMD agent-demo/> mysql、python、uvicorn、chcp
虚拟环境已激活的 CMD (venv) agent-demo/> 当前项目的 Python 命令
MySQL 客户端 mysql> SELECT、INSERT、USE 等 SQL
MySQL 等待继续输入 -> 补全当前 SQL 或取消输入
Swagger 网页表单 符合请求模型的 JSON

很多错误来自“命令本身没有问题,但输入到了错误的位置”。

5.2 进入数据库

在 CMD 中:

cmd
mysql -u root -p

输入密码时不显示字符是正常现象。若命令找不到,检查 mysql.exe 所在的实际安装目录及 PATH。不能仅凭“不是内部或外部命令”认定 MySQL 没安装。

当时先确认 Windows 服务中的 MySQL 正在运行,之后成功登录。原对话记录的版本查询结果为:

text
5.5.11

在 MySQL 中:

sql
SELECT VERSION();
SHOW DATABASES;
CREATE DATABASE agent_demo CHARACTER SET utf8mb4;
USE agent_demo;
SELECT DATABASE();

SELECT DATABASE(); 返回 agent_demo,表示当前连接已经选择目标库;如果返回 NULL,说明还没选。

接下来运行上一节的两张表建表语句,再检查:

sql
SHOW TABLES;
DESC conversations;
DESC messages;

这些创建命令用于首次搭建,不是要求每次启动应用都重建数据库。

5.3 手工插入会话与消息

sql
INSERT INTO conversations (conversation_id)
VALUES ('abc123');

INSERT INTO messages (conversation_id, role, content)
VALUES ('abc123', 'user', '我的名字叫岭西');

INSERT INTO messages (conversation_id, role, content)
VALUES ('abc123', 'assistant', '你好');

SELECT role, content
FROM messages
WHERE conversation_id = 'abc123'
ORDER BY id ASC;

手工插入的意义是先验证:

text
表结构正确
→ SQL 能执行
→ 内容能保存
→ 内容能查回

只有这条最底层链路稳定后,才让 Python 自动完成相同操作。

5.4 SQL 结束符和取消输入

如果忘记英文分号,看到:

text
mysql> SHOW DATABASES
    ->

这通常是等待输入,不是卡死。补英文 ; 即可。

如果已经混入中文分号、错误引号或其他错误字符,可先取消当前输入缓冲:

text
\c

回到 mysql> 后重新输入。

注意:

  • \c 是 MySQL 客户端命令。
  • 英文 , ; ' ( ) 用于 SQL 结构。
  • 字符串内容中的中文标点,例如 '好的,我记住了',是合法正文。

5.5 字段拼错后的真实修复

当时把 conversation_id 写成了 conversion_id。按 MySQL 5.5 的课堂环境,使用:

sql
ALTER TABLE conversations
CHANGE conversion_id conversation_id VARCHAR(100) NOT NULL;

DESC conversations;

CHANGE 同时需要写旧字段名、新字段名以及字段定义。执行前应核对现有结构,修改后核对约束;不需要为了修正字段名就删除整个表。


6. Python 连接 MySQL:配置与 db.py

6.1 为什么需要 PyMySQL

Python 程序需要一个驱动,负责与 MySQL Server 通信:

text
Python 业务代码
→ PyMySQL
→ MySQL 协议
→ MySQL Server
→ SQL 结果
→ Python 数据对象

PyMySQL 是驱动,不是数据库本身。安装它不会自动创建 MySQL Server,也不会自动建表。

在已有虚拟环境中,课堂使用:

cmd
python -m pip install pymysql

复习旧项目时应优先保留已跑通环境的依赖版本。以下示例沿用原有 API 写法,不代表 Python 3.8 可以安装每个库的任意最新版本。

6.2 .env 中保存连接配置

env
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=root
DB_PASSWORD=填写本机数据库密码
DB_NAME=agent_demo

这些是配置模板,不是真实凭据。

配置 含义
DB_HOST 数据库服务器地址
DB_PORT 数据库服务端口
DB_USER 登录数据库的用户名
DB_PASSWORD 对应密码
DB_NAME 连接后操作哪个数据库

127.0.0.1:3306 是本机 MySQL 的课堂连接地址;127.0.0.1:8000 是本机 Uvicorn/FastAPI 的课堂地址。相同 IP、不同端口,可以对应不同服务。

.env 应保留在本地,并在 .gitignore 中忽略;笔记和代码示例中只保留占位符。

6.3 config.py:把字符串配置转成需要的类型

本文件是整理后的最小配置示例。模型配置沿用原项目已经跑通的值,不要用示例文字代替真实设置。

python
# file: config.py
import os
from pathlib import Path

from dotenv import load_dotenv

load_dotenv(Path(__file__).resolve().parent / ".env")

API_KEY = os.getenv("API_KEY")
BASE_URL = os.getenv("BASE_URL")
DEFAULT_MODEL = os.getenv("DEFAULT_MODEL")

DB_HOST = os.getenv("DB_HOST", "127.0.0.1")
DB_PORT = int(os.getenv("DB_PORT", "3306"))
DB_USER = os.getenv("DB_USER")
DB_PASSWORD = os.getenv("DB_PASSWORD")
DB_NAME = os.getenv("DB_NAME", "agent_demo")

原项目已有 API_KEY、BASE_URL、DEFAULT_MODEL 这三个 Python 配置变量。若原先使用不同的环境变量名,例如在代码中读取 DASHSCOPE_API_KEY,应保留原映射,不能只改 .env 的一边。

这里最重要的是:

python
DB_PORT = int(os.getenv("DB_PORT", "3306"))

环境变量取出的通常是字符串 "3306";驱动连接参数需要整数 3306。如果写入非数字值,错误会发生在 int() 转换时,尚未进入数据库连接。

6.4 db.py:只负责建立连接

python
# file: db.py
import pymysql

from config import (
    DB_HOST,
    DB_PORT,
    DB_USER,
    DB_PASSWORD,
    DB_NAME,
)


def get_connection():
    return pymysql.connect(
        host=DB_HOST,
        port=DB_PORT,
        user=DB_USER,
        password=DB_PASSWORD,
        database=DB_NAME,
        charset="utf8mb4",
    )

get_connection() 返回一个连接对象,后续 SQL 通过这个对象创建 cursor 来执行。

charset="utf8mb4" 配置这条 Python 数据库连接使用的字符集。它不会自动改变另一个独立 MySQL 命令行窗口的设置。

6.5 最小连接测试

python
# file: test_db.py
from db import get_connection

connection = get_connection()
try:
    with connection.cursor() as cursor:
        cursor.execute("SELECT VERSION();")
        result = cursor.fetchone()
        print("MySQL版本:", result)
finally:
    connection.close()

当时实际读到:

text
MySQL版本: ('5.5.11',)

这证明了以下操作全部成功:

text
读取配置
→ 建立连接
→ 通过身份验证
→ 执行 SELECT VERSION()
→ 取出一行
→ 返回 Python

这比仅打印“连接成功”验证的范围更完整。

本笔记中 with 和 finally 是整理时补上的资源关闭写法。课堂原版是在函数末尾手动 cursor.close()、connection.close();核心读写接口不变。


7. connection、cursor、execute、fetch 与 commit

7.1 不要把几个对象当成同一件事

名称 可以怎样理解 典型代码
connection Python 与数据库的一条连接 get_connection()
cursor 通过连接执行 SQL、取结果的操作对象 connection.cursor()
execute 发送一条 SQL 及其参数 cursor.execute(sql, params)
fetchone 从查询结果中取下一行,没有则返回 None cursor.fetchone()
fetchall 取剩余查询结果行 cursor.fetchall()
commit 提交当前事务的写入 connection.commit()
rollback 回滚当前事务中尚未提交的改动 connection.rollback()
close 释放 cursor 或连接资源 connection.close()

7.2 execute 和 fetch 为什么分开

python
cursor.execute("SELECT role, content FROM messages;")
rows = cursor.fetchall()

第一行负责执行查询,第二行负责取回结果。execute() 的返回值不是“聊天历史列表”。

默认 tuple cursor 下,一行可能是:

python
("user", "我的名字叫岭西")

多行可能是:

python
(
    ("user", "我的名字叫岭西"),
    ("assistant", "你好"),
)

如果主动配置 DictCursor,行结构会不同。课堂转换代码使用的是默认 tuple 行,不能一边切换 cursor 类型,一边仍使用 row[0]、row[1]。

7.3 为什么只 SELECT role, content

LLM 的这段历史上下文只需要知道:

text
谁说的 + 说了什么

数据库中的行 id、创建时间等字段暂时不必全部发送给模型。读出后形成:

python
[
    {"role": "user", "content": "我的名字叫岭西"},
    {"role": "assistant", "content": "你好"},
]

这一步是存储结构与应用协议之间的转换。

7.4 参数化 SQL 与单元素 tuple

python
cursor.execute(
    """
    SELECT role, content
    FROM messages
    WHERE conversation_id = %s
    ORDER BY id ASC;
    """,
    (conversation_id,),
)

关键点:

  1. %s 是驱动参数占位符。
  2. 参数通过第二个参数单独交给驱动。
  3. 不要手工给 %s 再套一层引号。
  4. 不要用字符串拼接把用户输入直接嵌入 SQL。
  5. (conversation_id,) 末尾的逗号表示单元素 tuple。

对比:

python
value = "abc123"
a = (value)     # str,括号只是分组
b = (value,)    # tuple,只有一个元素

7.5 为什么写入需要 commit

课堂使用 PyMySQL 默认连接设置时,不能把执行 INSERT 直接等同于事务已经提交。写入后调用 connection.commit(),才能明确提交本次改动;官方例子也显式提交写入。PyMySQL 示例

要区分:

text
execute INSERT
→ 尝试执行写入

commit
→ 提交事务

close
→ 关闭连接,不应拿它代替 commit

事务行为还取决于表引擎;需要事务保证时应确认使用 InnoDB。课堂没有单独验证所有引擎与持久化配置,不能把简化代码理解成覆盖了所有数据库可靠性条件。


8. memory.py:读取、写入与确保会话存在

8.1 三个函数的关系

text
get_messages(conversation_id)
→ 读这一段会话的历史

ensure_conversation(conversation_id)
→ 没有这段会话时创建它

add_message(conversation_id, role, content)
→ 先确保会话存在,再保存一条消息

get_messages() 是读取操作。查不到消息时返回空列表,不应误解成已经在数据库中创建了会话。

8.2 get_messages 的逐步过程

text
接收 conversation_id
→ 建立数据库连接
→ SELECT role, content
→ WHERE 限定会话
→ ORDER BY 保证顺序
→ fetchall 取回行
→ tuple 行转为 dict
→ 组成 list
→ 关闭资源
→ return messages

转换部分:

python
messages = []
for row in rows:
    messages.append({
        "role": row[0],
        "content": row[1],
    })

row[0] 对应 SQL 中先选出的 role,row[1] 对应 content。如果 SELECT 列顺序改变,转换也必须跟着改变。

8.3 ensure_conversation 为什么必要

没有它时,应用可能先往 messages 插入:

text
new999 | user | 这是一个新的会话

但 conversations 中没有 new999。因为课堂没有加外键,数据库未必会阻止这种不完整关系。

课堂逻辑:

text
SELECT id WHERE conversation_id = new999
→ fetchone()

查到一行:
    不再重复 INSERT

返回 None:
    INSERT conversations
    commit

fetchone() 适合这里,是因为 conversation_id 有 UNIQUE 约束,最多应该存在一条匹配会话。

8.4 完整 memory.py

以下保留课堂三函数方案,同时补上异常时的资源关闭与事务回滚。仍是便于理解的单请求学习版,不是并发与原子写入升级版。

python
# file: memory.py
from typing import Dict, List

from db import get_connection


def ensure_conversation(conversation_id: str) -> None:
    connection = get_connection()
    try:
        with connection.cursor() as cursor:
            cursor.execute(
                """
                SELECT id
                FROM conversations
                WHERE conversation_id = %s;
                """,
                (conversation_id,),
            )
            result = cursor.fetchone()

            if result is None:
                cursor.execute(
                    """
                    INSERT INTO conversations (conversation_id)
                    VALUES (%s);
                    """,
                    (conversation_id,),
                )
                connection.commit()
    except Exception:
        connection.rollback()
        raise
    finally:
        connection.close()


def get_messages(conversation_id: str) -> List[Dict[str, str]]:
    connection = get_connection()
    try:
        with connection.cursor() as cursor:
            cursor.execute(
                """
                SELECT role, content
                FROM messages
                WHERE conversation_id = %s
                ORDER BY id ASC;
                """,
                (conversation_id,),
            )
            rows = cursor.fetchall()

            messages = []
            for row in rows:
                messages.append({
                    "role": row[0],
                    "content": row[1],
                })

        return messages
    finally:
        connection.close()


def add_message(
    conversation_id: str,
    role: str,
    content: str,
) -> None:
    ensure_conversation(conversation_id)

    connection = get_connection()
    try:
        with connection.cursor() as cursor:
            cursor.execute(
                """
                INSERT INTO messages (
                    conversation_id,
                    role,
                    content
                )
                VALUES (%s, %s, %s);
                """,
                (conversation_id, role, content),
            )
        connection.commit()
    except Exception:
        connection.rollback()
        raise
    finally:
        connection.close()

这里 raise 将原异常继续交给调用者,finally 无论函数正常返回还是出现异常,都会尝试执行资源关闭。

8.5 从 RAM 版到 SQL 版,调用者为什么不用大改

以前:

text
main.py → get_messages → dict

现在:

text
main.py → get_messages → PyMySQL → MySQL

返回给 main.py 的结构仍然是历史消息列表。接口稳定,底层实现可替换,这就是本阶段最具体的“解耦”。

8.6 实际测试与重复数据

读取测试:

python
from memory import get_messages

messages = get_messages("abc123")
print(messages)

当时已拿到:

python
[
    {"role": "user", "content": "我的名字叫岭西"},
    {"role": "assistant", "content": "你好"},
]

写入新会话测试:

python
from memory import add_message

add_message("new999", "user", "这是一个新的会话")
print("保存完成")

检查:

sql
SELECT *
FROM conversations
WHERE conversation_id = 'new999';

SELECT role, content
FROM messages
WHERE conversation_id = 'new999'
ORDER BY id ASC;

两张表都有对应数据,才说明“先确保会话存在,再保存消息”完整生效。

重复运行写入脚本时:

text
conversations:
new999 仍然只有一个会话记录

messages:
每执行一次 add_message,都会新增一条消息

重复消息不一定是循环坏了,也可能只是反复运行了真实 INSERT。ensure_conversation() 防止重复建会话,不负责消息去重。

8.7 当前实现的两个边界

**先查后插的并发竞争。**两个请求可能同时查到“会话不存在”,随后都尝试 INSERT,其中一个触发 UNIQUE 冲突。课堂方案适合先理解流程,不等于已经解决并发创建。

**一个函数一次提交,不等于整轮对话一起提交。**后面调用两次 add_message(),会分别提交。第二次失败时,第一次可能已经成功。后续若需要“一轮 user + assistant 要么都成功、要么都不保存”,需要在同一个事务中统一写入,而不是仅仅调整调用顺序。


9. MySQL Memory 接回 FastAPI

9.1 先完成持久化 Chatbot,再接 Agent

第 41 课时还没有 Agent Loop,核心过程是:

text
收到 conversation_id + question
→ get_messages 读取历史
→ 加入当前问题
→ call_model 调用 Qwen
→ 得到回答
→ add_message 保存 user
→ add_message 保存 assistant
→ 返回客户端

这一阶段原请求模型包含:

python
from typing import Optional

from pydantic import BaseModel


class ChatRequest(BaseModel):
    conversation_id: str
    question: str
    model: Optional[str] = None

后来第 52 课简化为 conversation_id + message,最终 Agent 示例不再接收 model 字段,而使用 DEFAULT_MODEL。这是接口变更,不是 question 与 message 可以自动互换。

9.2 读取历史与追加消息是两件事

核心片段如下,省略原项目已有的路由导入和模型调用实现:

python
history = get_messages(request.conversation_id)

messages = []
messages.extend(history)
messages.append({
    "role": "user",
    "content": request.question,
})

answer = call_model(messages=messages, model=request.model)

add_message(request.conversation_id, "user", request.question)
add_message(request.conversation_id, "assistant", answer)

为专注本次范围,片段省略了旧 Chatbot 中已有的基础角色说明;这里不展开第 54 课的规则设计。

extend(history) 把历史中的每条消息分别加入列表:

text
[user, assistant, user, assistant]

如果误用 append(history),会把整个历史列表作为一个元素塞进去:

text
[[user, assistant, user, assistant]]

嵌套层级不符合当前消息结构。

9.3 为什么先调用模型,再保存

本阶段选择的保存策略是:

先取得本轮回答,再保存这一轮对话。

若先保存 user,之后模型连接失败,数据库里就可能留下没有对应回答的问题。

改成先调用模型,可以避免模型调用失败造成的这类半轮写入。但它不能保证后续两个数据库写入不会中途失败,事务边界仍需区分。

这种策略也不是所有聊天产品的唯一正确方案。记录失败请求、重试状态的系统可能先保存用户消息,但需要额外设计状态字段。本课程还没有实现那种流程。

9.4 持久化会话的测试

第一次请求:

json
{
  "conversation_id": "persist001",
  "question": "我的名字叫岭西,请记住"
}

检查数据库:

sql
SELECT role, content
FROM messages
WHERE conversation_id = 'persist001'
ORDER BY id ASC;

第二次仍使用同一个 conversation_id:

json
{
  "conversation_id": "persist001",
  "question": "我叫什么名字?"
}

原对话已记录:接口返回 200,MySQL 保存了名字相关的 user / assistant 历史,追问时能回答“岭西”。

9.5 怎样更强地证明不依赖 RAM

复现步骤:

text
确认数据库已有历史
→ 停止 Uvicorn
→ 重新启动 Uvicorn
→ 用 persist001 再问名字
→ 检查仍然能回答

原课程要求做过这项测试,并判定该阶段完成;但读取到的逐条记录没有单独展示完整的停止、启动终端过程。因此,记忆复现与数据库写入是已展示的结果,重启过程不额外伪造截图或日志。

9.6 conversation_id 不是用户身份认证

相同 conversation_id 用来读取同一段历史,换一个 ID 会查询另一段历史。

不过,一个可由客户端任意填写的会话 ID,只是定位键。当前课堂项目没有实现“这个用户有权读取这段历史”的鉴权机制。数据按 ID 区分,不等于已经完成多用户权限隔离。


10. 从 Chatbot 到 Agent

10.1 有了 Memory,为什么还要工具和循环

持久化 Chatbot 能:

text
理解当前问题
→ 参考历史
→ 生成文本回答

但如果任务要求实时信息或真实动作,例如查天气、计算、生成文件,仅靠历史不能自动完成外部操作。

本项目采用的 Agent 结构:

text
LLM:理解任务和决定下一步
Tools:提供真实能力
Python Runtime:调度并执行
Memory:提供已有背景
Loop:让决策和执行反复衔接

这是本项目的学习模型,不是声称所有被称为 Agent 的系统都必须采用完全相同的组件或持久化方式。

10.2 Reason / Act / Observe 分别是什么

步骤 问题 在计算器示例中的表现
Reason 接下来需要做什么? 模型提出 calculator 调用与参数
Act 真正执行动作 Python 执行乘法或除法函数
Observe 动作返回了什么? 工具输出 96、24.0 或错误信息
再次 Reason 是否需要继续? 模型根据工具结果决定下一步或回答

本人最初把 Observe 理解为“提取模型输出”。应修正为:

Observe 关注工具或环境执行后返回的结果,而不是仅仅读取模型生成了什么文字。

这里的 Reason 是对模型决策环节的概念描述。tool_calls 展示模型提出的动作,不等于显示了模型内部完整思考过程。

10.3 谁决定,谁执行

text
程序员:
定义函数、参数规则、允许的工具

LLM:
选择这次是否调用、调用哪个工具、给出哪些参数值

Python:
查找函数、解析参数、真正执行

Tool:
完成具体计算或外部操作

LLM:
读取执行结果,再组织回答或提出下一次动作

模型返回 "calculator",并不意味着 Python 函数已经被调用。没有应用代码执行这一步,工具就不会自动运行。

10.4 Agent Loop 与 Workflow / Flow 的区别

本人曾理解成“Flow 只能执行一次,Loop 可以执行多次”。这不准确。

对比 Workflow / Flow 本课程 Agent Loop
路径 主要由程序预先编排 模型根据当前状态决定后续动作
能否多个步骤 可以 可以
能否多个工具 可以 可以
能否包含循环 可以 核心就有循环
重点 明确流程编排 动态选择与反复观察

两者也能组合。不能用“有没有多个工具”作为唯一判断标准。

10.5 工具结果不自动保证最终答案正确

工具提供了可核对的执行结果,但仍可能出现:

  • 模型选错工具;
  • 参数不符合用户意图;
  • 工具本身出错;
  • 模型在最终回答中误读结果。

因此要保留 Tool Call 与 Tool Result 的可观察记录,用来区分错误发生在哪一层。


11. Tool、Tool Schema 与 Function Calling

11.1 先写一个普通 Python 函数

课堂选择 calculator,是因为它不依赖额外天气 API,输入与输出容易检查。

python
def calculator(a: float, b: float, operation: str) -> float:
    if operation == "add":
        return a + b
    elif operation == "subtract":
        return a - b
    elif operation == "multiply":
        return a * b
    elif operation == "divide":
        return a / b
    else:
        raise ValueError("不支持的运算类型")

手工调用:

python
result = calculator(12, 8, "multiply")
print(result)

结果是 96,但这时只是程序员写死了调用。

11.2 参数定义和参数值是谁决定的

text
a、b、operation 这些参数名与含义
→ 程序员定义

本次 a=12、b=8、operation=multiply
→ 模型根据用户问题选择

模型没有重新定义函数签名,而是在已经给出的参数规则内生成一次调用参数。

a: float 等类型提示便于理解和检查,但普通 Python 函数不会仅因有类型提示就自动进行完整输入验证。传入不合适的对象仍可能在运算时失败。

11.3 怎样让模型知道工具存在

模型看不到电脑上任意一个 tools.py 的源码。应用要在请求中发送工具说明,即 Tool Schema:

python
calculator_tool = {
    "type": "function",
    "function": {
        "name": "calculator",
        "description": "执行两个数字之间的基础数学运算",
        "parameters": {
            "type": "object",
            "properties": {
                "a": {
                    "type": "number",
                    "description": "第一个数字",
                },
                "b": {
                    "type": "number",
                    "description": "第二个数字",
                },
                "operation": {
                    "type": "string",
                    "description": "运算类型",
                    "enum": [
                        "add",
                        "subtract",
                        "multiply",
                        "divide",
                    ],
                },
            },
            "required": ["a", "b", "operation"],
        },
    },
}

11.4 Schema 逐层解释

字段 告诉模型什么
外层 type: function 这是函数工具
name 提出调用时使用哪个名字
description 这个工具能解决什么问题
parameters 参数结构
type: object 参数整体是一个对象,对应解析后的 dict
properties 有哪些参数,以及它们的含义和类型
number 数字类型,不只是整数
enum 运算类型的候选值
required 本次调用需要提供哪些字段

Schema 是说明,不是函数实现。即使把 description 写得很清楚,如果没有真正执行的 calculator,仍然无法完成运算。

Schema 也不等于本地已运行参数校验器。课堂代码仍需要解析和异常处理。

11.5 把 Schema 发给模型

python
completion = client.chat.completions.create(
    model=DEFAULT_MODEL,
    messages=messages,
    tools=[calculator_tool],
)

tools 接收的是工具说明列表,不是 calculator 函数对象,也不是 "tools.py" 这个文件名。

原项目使用 OpenAI Python SDK 的兼容调用方式连接 Qwen;使用这个 SDK,不代表后台运行的模型就是 OpenAI 模型。具体服务由 BASE_URL、凭据和模型配置决定。

11.6 Function Calling 是一份结构化调用请求

成功返回过的结构可以简化为:

text
ChatCompletionMessage(
    role="assistant",
    content="",
    tool_calls=[
        ToolCall(
            id="call_...",
            function={
                name: "calculator",
                arguments: '{"a": 12, "b": 8, "operation": "multiply"}'
            }
        )
    ]
)

这说明模型提出:

text
请使用 calculator
参数是 12、8、multiply

它不是已经计算出工具结果。此时 content="" 并不表示模型调用失败,应同时检查 tool_calls。


12. 解析 tool_calls 并执行函数

12.1 一层一层取出对象

python
message = completion.choices[0].message
tool_call = message.tool_calls[0]
tool_name = tool_call.function.name
arguments_text = tool_call.function.arguments

关系:

text
completion
└─ choices 列表
   └─ 第 0 个候选
      └─ message
         ├─ content
         └─ tool_calls 列表
            └─ 某一次 tool_call
               ├─ id
               └─ function
                  ├─ name
                  └─ arguments

这里拿 [0] 是初学阶段的单调用示例。正式 Loop 后续会遍历整个列表。

12.2 为什么需要 json.loads

原始 arguments 是 JSON 文本:

python
arguments_text = '{"a": 12, "b": 8, "operation": "multiply"}'

它仍然是 str,不能把它直接当成参数字典。

转换:

python
import json

arguments = json.loads(arguments_text)

得到:

python
{
    "a": 12,
    "b": 8,
    "operation": "multiply",
}

于是才能:

python
result = calculator(
    a=arguments["a"],
    b=arguments["b"],
    operation=arguments["operation"],
)

12.3 对象属性、字典取值与 JSON 转换

写法 适用对象
message.content SDK 对象的属性
tool_call.function.name 嵌套 SDK 对象属性
arguments["a"] Python dict 取值
json.loads(text) JSON 字符串 → Python 对象
json.dumps(obj) Python 对象 → JSON 字符串

不要因为打印出来像字典,就假定所有对象都支持同一套取值方式。

12.4 这一阶段验证了什么

原对话已成功输出:

text
模型选择工具: calculator
模型生成参数: {'a': 12, 'b': 8, 'operation': 'multiply'}
工具执行结果: 96

这验证了:

text
模型选工具
→ 生成参数
→ Python 解析
→ 真正执行函数

但若只打印 96 就结束,还没有验证模型“读取工具结果再回答”的后半段。


13. 把 Tool Result 回传给模型

13.1 print 不等于发给模型

python
print(result)

只把结果输出到终端。Qwen 不会因为终端显示了 96,就自动知道函数执行结果。

必须把结果加入下一次模型调用的上下文。

13.2 先保存模型提出的 Tool Call

课堂写法:

python
messages.append(message)

这里保存的是模型刚返回的 assistant message,其中带有 tool_calls。

否则后面只给一条 tool result,协议上下文中没有对应的调用请求,结果就失去了关联。

课堂 SDK 版本已经实际接受这种对象追加方式;它和 MySQL 查询出来的 dict 一起构成运行时列表。若其他 SDK 版本需要字典,应按其支持的序列化方式转换,并保留 role、tool_calls 及调用 id,不能只留下 content。

13.3 添加 tool message

python
messages.append({
    "role": "tool",
    "tool_call_id": tool_call.id,
    "content": str(result),
})

三个字段各有作用:

  • role="tool":说明这条内容来自工具执行结果。
  • tool_call_id:说明回应的是哪一次调用。
  • content:结果文本;数字先转换为字符串。

这里的 role、调用 id 关联及结果回传方式,也可对照官方 Chat Completions 示例核对。OpenAI Function calling

13.4 tool_call_id 不是工具名字

text
name = calculator
→ 调用哪个工具

id = call_aaa
→ 本轮中的哪一次具体调用

同一个 calculator 可以调用多次,所以不能拿 "calculator" 代替所有调用的 id。

示意:

text
call_a → calculator(12, 8, multiply) → 96
call_b → calculator(96, 4, divide)  → 24.0

每条 tool result 必须与自己的 call id 配对。

13.5 完整上下文示意

json
[
  {
    "role": "user",
    "content": "请使用计算器计算12乘以8"
  },
  {
    "role": "assistant",
    "content": "",
    "tool_calls": [
      {
        "id": "call_example",
        "type": "function",
        "function": {
          "name": "calculator",
          "arguments": "{\"a\":12,\"b\":8,\"operation\":\"multiply\"}"
        }
      }
    ]
  },
  {
    "role": "tool",
    "tool_call_id": "call_example",
    "content": "96"
  }
]

再把这个列表交给模型,模型才有机会根据真实结果回答“12 乘以 8 等于 96”。

这段 JSON 是结构示意,call_example 是占位 id,不是本人当时返回的真实标识。

13.6 多个调用要全部补结果,再发下一次模型请求

假设一个 assistant message 里提出两个 tool_calls:

text
assistant:call_a + call_b
tool:call_a 的结果
tool:call_b 的结果
下一次 LLM 请求

不应该只执行第一个就把上下文交回模型,遗漏第二个结果。否则上下文协议可能不完整。


14. Agent Loop:while、for 与多个工具调用

14.1 为什么固定两次模型调用不够

最初写死的流程:

text
第一次 LLM
→ 执行工具
→ 第二次 LLM
→ 结束

如果第二次模型仍然需要调用工具,这份程序就没法继续。

需要把“模型返回调用请求”与“模型返回最终回答”放进循环中处理。

14.2 while 负责反复决策

以下是课堂循环骨架,省略了工具执行代码:

python
while True:
    completion = client.chat.completions.create(
        model=DEFAULT_MODEL,
        messages=messages,
        tools=tool_schemas,
    )
    message = completion.choices[0].message
    messages.append(message)

    if not message.tool_calls:
        print("最终回答:", message.content)
        break

    for tool_call in message.tool_calls:
        # 解析参数、执行工具、追加 tool message
        pass

这个片段用于看缩进结构,不能把 pass 当成已经实现工具处理。完整可对照版本见第 17 节。

14.3 while 和 for 分别管什么

text
while:
Agent 还要不要再次请求模型?

for:
这一次模型返回的多个 tool_calls,要逐个处理。

外层 while 是模型决策轮次;内层 for 是当前轮次的调用列表。

例如:

text
Step 1:
LLM 返回两个 Tool Call
for 执行两次

Step 2:
LLM 没有 Tool Call
直接返回最终答案

总共两轮模型调用、两次工具执行、一个用户 HTTP 请求。三种计数不能混淆。

14.4 break、continue、return

语句 影响
break 退出最内层循环,不会自动退出整个函数
continue 跳过本轮剩余语句,进入最近一层循环的下一轮
return value 结束当前函数,并将 value 返回给调用者
循环体自然结束 回到该循环下一次判断或取下一元素

如果处于内层 for 中,break 只退出 for,不会同时退出外层 while。

函数化以后,得到最终回答时直接 return message.content,可以同时结束函数和其中的循环。

14.5 依赖式多轮,与同一轮多个调用

理想的有依赖过程:

text
LLM:先提出 12 × 8
→ Python 算出 96
→ 把 96 给 LLM
→ LLM 再提出 96 ÷ 4
→ Python 算出 24.0
→ 把 24.0 给 LLM
→ 最终答案

但本人模块化后的真实日志是:

text
Step 1:模型一次生成
    calculator(a=12, b=8, operation=multiply)
    calculator(a=96, b=4, operation=divide)

Python 用 for 顺序执行,返回 96 与 24.0

Step 2:模型返回最终答案

这里第二个参数 a=96 在第一条工具执行前就已经被模型生成了。不能据此声称第二个调用参数来自模型已经观察到的第一条真实结果。

它证明的是:

  • 一次模型响应可以包含多个工具调用;
  • 当前代码能处理全部调用并回传结果;
  • 当前执行方式是 for 顺序执行;
  • “同一轮多个调用”本身不等于 Python 并发执行;
  • 处理任意依赖任务时,不能依靠模型预先猜测中间结果。

本阶段没有实现一般化的依赖调度器,也没有实现工具并发执行。


15. Tool Registry 与 **arguments

15.1 为什么不能一直写 if / elif

只有 calculator 时可以:

python
if tool_name == "calculator":
    result = calculator(
        a=arguments["a"],
        b=arguments["b"],
        operation=arguments["operation"],
    )

工具一多,核心 Loop 会充满各个工具特有的名字和参数。

Tool Registry 把“名字对应哪个函数”独立出来:

python
tool_registry = {
    "calculator": calculator,
}

15.2 为什么 value 没有括号

text
"calculator": calculator

保存的是函数对象,表示“以后找到这个函数再调用”。

python
calculator()

表示立即调用函数。如果放进注册表时就写括号,不仅时机不对,还可能因缺少参数报错;即使调用成功,保存的也会是返回值。

函数可以像其他对象一样被放进 dict、赋给变量、传递给其他函数。

15.3 查找真正的函数

python
tool_function = tool_registry.get(tool_name)

找到时:

text
tool_function → calculator 函数对象

找不到时:

text
tool_function → None

dict.get() 并不会自动解释“哪里出错”,只是让不存在的 key 返回默认值(默认 None),由后续代码决定如何处理。

对比:

python
tool_registry[tool_name]

key 不存在时直接抛出 KeyError。

15.4 **arguments 是关键字参数展开

python
arguments = {
    "a": 12,
    "b": 8,
    "operation": "multiply",
}

result = tool_function(**arguments)

等价于:

python
result = tool_function(a=12, b=8, operation="multiply")

** 在这里把字典按:

text
key → 参数名
value → 参数值

展开后传给函数。

它的价值不只是让代码短,而是使调度代码不必为每个工具手写不同参数列表。

15.5 使用 **arguments 的前提

  • arguments 应该是可用于关键字展开的映射,通常是 dict。
  • key 应对应函数支持的参数名。
  • 必需参数不能缺失。
  • 不支持的额外参数可能触发 TypeError。
  • 参数值仍应满足函数实际要求。

例如 {"x": 12} 并不会自动被重命名为 a。**arguments 是展开,不是“智能修正参数”。

15.6 Schema、Registry、函数三者对应

对象 主要给谁使用 作用
Tool Schema LLM / 模型服务 描述工具与参数
Tool Registry Python Runtime 按名字找到可调用函数
函数实现 Python 执行 真正完成动作

命名对应:

text
Schema 中 name = calculator
        ↓
模型输出 name = calculator
        ↓
Registry 中 key = calculator
        ↓
calculator 函数

如果 Schema 叫 calculator,Registry 的 key 却叫 calculate,就会“模型知道有工具,Python 却找不到”。


16. max_steps 与错误处理

16.1 为什么不能只依赖 while True

模型可能反复提出调用,造成:

text
LLM 请求
→ 工具调用
→ LLM 请求
→ 工具调用
→ 持续重复

因此在程序层设置上限:

python
step = 0
while step < max_steps:
    step += 1
    # 当前一轮处理

step += 1 等价于 step = step + 1。

16.2 max_steps 在课堂代码中限制什么

限制的是外层模型决策循环次数。

如果 max_steps=10,每轮都返回 3 个调用,就可能执行多于 10 次工具。因此它不是“最多执行 10 个函数”。

它也不是时间超时,不会自动打断卡住的网络请求或工具。SDK 内部重试也不等同于 step 计数。本阶段只建立了循环轮次上限。

16.3 正常完成与达到上限怎样区分

脚本阶段使用过:

python
finished = False

正常完成时设置 True 并 break,循环后判断 finished。

这样比:

python
if step >= max_steps:
    print("达到最大执行步数")

更准确,因为恰好第 10 轮正常完成,也满足 step >= max_steps。

函数化后的更直接写法:

text
循环内发现最终回答 → return 最终回答
循环自然耗尽       → return 超限提示

16.4 未知工具

python
tool_function = tool_registry.get(tool_name)

if tool_function is None:
    result = "工具不存在:" + tool_name
else:
    result = tool_function(**arguments)

找到 None 后不能继续把它当函数调用。未知工具的错误信息也应与对应 tool_call_id 关联回传。

16.5 工具执行异常

python
try:
    result = tool_function(**arguments)
except Exception as e:
    result = "工具执行失败:" + str(e)

例子:

python
calculator(a=10, b=0, operation="divide")

会抛出除零异常。捕获后把错误当作结果文本回传:

text
工具执行失败:division by zero

再交给模型,模型可以解释为什么这次操作失败。

**错误也可以成为 Observation。**但这不等于模型一定能修好问题,也不代表工具真正成功了。

16.6 try 的范围决定能捕获什么

课堂代码中:

python
arguments = json.loads(tool_call.function.arguments)

try:
    result = tool_function(**arguments)
except Exception as e:
    result = "工具执行失败:" + str(e)

这里只捕获执行函数阶段的异常。若 JSON 本身不合法,错误发生在 try 之前,仍然会向外抛出。

整理补充:以后可以把解析阶段单独包起来,区分:

text
无法解析 JSON
参数不是对象
工具名不存在
参数不匹配
工具内部执行失败

不能只看到一处 except,就认为全链路已经不会出错。

16.7 工具错误、模型错误、数据库错误属于不同层

错误来源 当前处理思路
工具名不存在 生成 tool 错误结果回传
calculator 除零 捕获执行异常,作为 Observation
JSON 解析错误 课堂版未在工具分支捕获
模型服务连接失败 从模型请求抛到调用者
MySQL 连接或写入失败 从 memory.py 抛到路由
循环达到上限 课堂版返回停止提示字符串

max_steps、Registry 和 try/except 是控制范围不同的保护措施,不能互相替代。


17. 模块化后的完整核心代码

17.1 为什么从 tool_call_test.py 拆出来

最初测试脚本同时承担:

text
配置模型客户端
定义工具 Schema
管理工具 Registry
准备问题
运行 Agent Loop
打印结果

继续增加功能时会难以维护。拆分后,每个模块只承担清晰的一部分职责。

17.2 当前项目结构

text
agent-demo/
├─ .env
├─ .gitignore
├─ requirements.txt
├─ config.py
├─ db.py
├─ memory.py
├─ model.py
├─ tools.py
├─ agent.py
├─ main.py
├─ test_db.py
├─ linshitest.py
├─ conversationtest.py
└─ tool_call_test.py

这是原项目的学习路径与文件名示意,本次只交付学习笔记,没有在这个目录改写项目。

模块 当前职责 不应混入的主要职责
config.py 读取配置 执行工具、处理 HTTP
db.py 建立数据库连接 拼接模型上下文
memory.py 会话与消息的 SQL 读写 模型决策
tools.py 函数、Schema、Registry HTTP 路由
agent.py 模型请求、工具调度、循环、最终返回 本阶段不直接查询 MySQL
main.py HTTP 输入输出,协调 Memory 和 Agent 不为每个工具手写执行分支
model.py 保留旧普通模型调用逻辑 第 52 课尚未完成新一轮模型层重构
tool_call_test.py 提供测试输入,调用 run_agent,打印答案 不继续复制完整 Loop

**测试文件并不是直接把结果返回给 Qwen的地方。**它调用 run_agent(),Agent 内部才负责调用 Qwen和回传工具结果。

17.3 tools.py 完整版

python
# file: tools.py
def calculator(
    a: float,
    b: float,
    operation: str,
) -> float:
    if operation == "add":
        return a + b
    elif operation == "subtract":
        return a - b
    elif operation == "multiply":
        return a * b
    elif operation == "divide":
        return a / b
    else:
        raise ValueError("不支持的运算类型")


calculator_tool = {
    "type": "function",
    "function": {
        "name": "calculator",
        "description": "执行两个数字之间的基础数学运算",
        "parameters": {
            "type": "object",
            "properties": {
                "a": {
                    "type": "number",
                    "description": "第一个数字",
                },
                "b": {
                    "type": "number",
                    "description": "第二个数字",
                },
                "operation": {
                    "type": "string",
                    "description": "运算类型",
                    "enum": [
                        "add",
                        "subtract",
                        "multiply",
                        "divide",
                    ],
                },
            },
            "required": ["a", "b", "operation"],
        },
    },
}

tool_schemas = [calculator_tool]

tool_registry = {
    "calculator": calculator,
}

增加工具时,至少要同步完成函数实现、Schema 和 Registry。只加一个函数,而没有把它描述给模型、放进注册表,并不会自动成为可调用能力。

17.4 agent.py 完整课堂版

下面保留第 51 课修复后的核心写法,包括传入列表会被追加修改、JSON 解析在执行异常捕获之外、超限返回字符串等课堂特征,方便对照实际日志。

python
# file: agent.py
import json

from openai import OpenAI

from config import API_KEY, BASE_URL, DEFAULT_MODEL
from tools import tool_registry, tool_schemas


client = OpenAI(
    api_key=API_KEY,
    base_url=BASE_URL,
)


def run_agent(messages, max_steps: int = 10):
    step = 0

    while step < max_steps:
        step += 1
        print("当前Step:", step)

        completion = client.chat.completions.create(
            model=DEFAULT_MODEL,
            messages=messages,
            tools=tool_schemas,
        )
        message = completion.choices[0].message

        print("模型 content:", message.content)
        print("模型 tool_calls:", message.tool_calls)

        # 保留模型的调用请求,后面的 tool result 才有对应关系。
        messages.append(message)

        if not message.tool_calls:
            return message.content

        for tool_call in message.tool_calls:
            tool_name = tool_call.function.name
            arguments = json.loads(tool_call.function.arguments)

            print("模型选择工具:", tool_name)
            print("模型生成参数:", arguments)

            tool_function = tool_registry.get(tool_name)

            if tool_function is None:
                result = "工具不存在:" + tool_name
            else:
                try:
                    result = tool_function(**arguments)
                except Exception as e:
                    result = "工具执行失败:" + str(e)

            print("工具执行结果:", result)

            messages.append({
                "role": "tool",
                "tool_call_id": tool_call.id,
                "content": str(result),
            })

    # 必须与 while 同一级,表示循环耗尽后才执行。
    return "Agent达到最大执行步数,任务未完成"

17.5 沿执行顺序读懂 run_agent

  1. 设置 step 为 0。
  2. 检查是否还有轮次额度。
  3. step 加 1,打印进入了哪一轮。
  4. 把当前 messages 与工具说明交给模型。
  5. 取本轮 assistant message。
  6. 把 assistant message 加回运行时上下文。
  7. 没有 tool_calls:把 content 返回调用者。
  8. 有 tool_calls:逐个解析、查找、执行、回传结果。
  9. for 结束后,回到 while 的下一轮。
  10. 如果一直没有最终回答,循环耗尽后返回停止提示。

第 8 步之后必须能够走回第 2 步,这正是后来缩进错误破坏的地方。

17.6 return 返回给谁

在测试脚本中:

python
answer = run_agent(messages)
print("最终回答:", answer)

返回给调用者 tool_call_test.py,值被赋给 answer。

在接口中:

python
answer = run_agent(messages)

返回给 main.py 的 chat 函数。

与此不同:

python
messages.append({
    "role": "tool",
    "tool_call_id": tool_call.id,
    "content": str(result),
})

只是准备好给模型的上下文,下一次 client.chat.completions.create() 才把它发给模型。

“Python 函数 return”与“Tool Result 回传模型”是两个不同的方向。

17.7 tool_call_test.py 完整版

python
# file: tool_call_test.py
from agent import run_agent


if __name__ == "__main__":
    messages = [
        {
            "role": "user",
            "content": "请使用计算器,先计算12乘以8,再把结果除以4。",
        }
    ]

    answer = run_agent(messages)
    print("最终回答:", answer)

if __name__ == "__main__": 是整理时补充的测试入口保护:直接运行这个脚本才执行测试,其他文件导入它时不自动发起模型请求。

在项目目录中运行:

cmd
python tool_call_test.py

不要再把 Loop 复制进测试文件;以后修改调度逻辑,只修改 agent.py。

17.8 这份课堂版代码还保留哪些限制

  • messages 会被原地追加,调用者看到的是同一个 list。
  • 没有工具调用时直接返回 content,没有单独处理空回答、截断等状态。
  • JSON 解析失败会向外抛异常。
  • max_steps 耗尽返回普通字符串,没有独立状态。
  • 没有工具执行超时和总耗时上限。
  • 没有结构化持久化 Trace。

这些限制需要知道,但不在本笔记中伪装成“本人已实现”。


18. Agent 接入 FastAPI + MySQL Memory

18.1 接入的关键只是替换处理入口

原来:

text
历史 + 当前问题 → call_model → answer

现在:

text
历史 + 当前问题 → run_agent → answer

FastAPI 不必知道里面用了多少次工具。它只负责提供输入、拿到返回、按策略保存。

18.2 main.py 完整课堂版

python
# file: main.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

from agent import run_agent
from memory import get_messages, add_message


app = FastAPI()


class ChatRequest(BaseModel):
    conversation_id: str
    message: str


@app.post("/chat")
def chat(request: ChatRequest):
    try:
        messages = get_messages(request.conversation_id)

        messages.append({
            "role": "user",
            "content": request.message,
        })

        answer = run_agent(messages)

        add_message(
            request.conversation_id,
            "user",
            request.message,
        )
        add_message(
            request.conversation_id,
            "assistant",
            answer,
        )

        return {"answer": answer}

    except Exception as e:
        raise HTTPException(
            status_code=500,
            detail=str(e),
        )

这里使用同步 def,配合课堂里的同步 PyMySQL 和模型调用。把函数名改成 async def 本身不会让里面的阻塞调用自动变成异步。

18.3 请求字段必须与模型一致

最终请求:

json
{
  "conversation_id": "agent001",
  "message": "请使用计算器,先计算12乘以8,再把结果除以4。"
}

如果仍发:

json
{
  "conversation_id": "agent001",
  "question": "请计算12乘以8"
}

就缺少当前 ChatRequest 必需的 message 字段。应检查 Swagger 当前显示的 Schema,并同步修改请求体与 request.message,不要把这种输入校验问题归因于 Agent。

Pydantic 请求模型是 FastAPI 解释和校验 JSON 请求体的依据。FastAPI Request Body

18.4 一次完整请求的数据生命周期

假设数据库已保存:

text
user:我的名字叫岭西
assistant:你好,岭西

当前请求问:

text
请计算12乘以8,再把结果除以4

步骤 1:查询数据库

text
messages = get_messages(conversation_id)

messages:
旧 user
旧 assistant

步骤 2:当前问题先加入 RAM 列表

text
messages:
旧 user
旧 assistant
本次 user

此时 append 改的是 Python 对象,尚未 INSERT 新消息。

步骤 3:Agent 在同一个运行时列表中继续追加

text
messages:
旧 user
旧 assistant
本次 user
本轮 assistant(tool_calls)
本轮 tool result
本轮 tool result
最终 assistant

步骤 4:run_agent 返回最终文本

text
answer = 最终结果是24.0

步骤 5:显式保存本轮两条消息

text
INSERT 当前 user
INSERT 最终 assistant

最后数据库是:

text
旧 user
旧 assistant
本次 user
本次最终 assistant

18.5 为什么不能遍历整个 messages 再写回

错误思路:

text
for message in messages:
    全部写入 MySQL

会造成两个问题:

  1. 原有历史被重复插入。
  2. assistant tool_calls 与 tool results 也被混入普通聊天历史。

正确做法是使用请求里的原始用户文本与 run_agent 返回的 answer,明确保存这两条。

18.6 模型回答中提到 96,不等于数据库存了 Trace

实际数据库的最终 assistant 正文里包括了:

text
12 × 8 = 96
96 ÷ 4 = 24.0
最终结果 24.0

这是用户可见的最终回答内容。

因此不能用“数据库文本里有没有 96 或 24”判断是否混入内部轨迹。真正要看:

text
是否新增 role=tool 的独立记录?
是否保存 assistant 的结构化 tool_calls?
是否保存了内部 tool_call_id 等字段?

本人截图中确实只有 user 和 assistant 两行;最终回答正文包含计算步骤,并不违反这一设计。

18.7 课堂 HTTP 错误处理的边界

最终示例的 except Exception 用来先跑通整合,它把进入路由后发生的异常统一转成 500。

而在更早的普通模型调用阶段,已经讨论过:

异常类型 课堂用过的映射示例
APIConnectionError 503,暂时连接不上模型
AuthenticationError 500,服务端模型凭据配置问题
NotFoundError 400,请求的模型不存在或不可用
RateLimitError 429,请求过于频繁
APIStatusError 502,上游模型服务异常

这些是应用层的设计选择,不是 FastAPI 自动完成的统一映射。第 52 课简化整合代码时没有保留所有细分类别。

还要注意:

text
run_agent 超限
→ 返回“任务未完成”字符串
→ 路由没有收到异常
→ 仍可能保存该字符串并返回 200

所以“HTTP 200”只说明当前 HTTP 处理完成,不能单独证明 Agent 成功完成了用户任务。后续可用状态字段或专用异常区分 completed / failed / step_limit,但当前尚未这样重构。


19. Chat History、运行时上下文与 Agent Trace

19.1 Chat History:对话说了什么

例如:

text
user:
请先计算12乘以8,再把结果除以4。

assistant:
最终结果是24.0。

user:
刚才最终算出来是多少?

assistant:
刚才最终算出来是24.0。

主要价值是让后续模型知道用户与助手之前交流了什么。本阶段放在 MySQL 的 messages 表中持久化保存。

19.2 Agent Trace:这次任务怎样执行

例如:

text
开始运行
→ Step 1
→ 模型提出 calculator 调用
→ arguments = 12、8、multiply
→ 工具返回 96
→ 第二个 calculator 调用
→ arguments = 96、4、divide
→ 工具返回 24.0
→ Step 2
→ 模型返回最终回答
→ 运行结束

在更完整的日志系统中,还可以记录运行 id、错误、耗时等。但本阶段主要是 messages 中的调用/结果与终端 print,没有已经建好的 agent_runs 或 tool_calls 日志表。

19.3 run_agent 里的 messages 不是“纯日志”

本人第 53 课把 run_agent 中的 messages 描述为执行流程日志。需要校准为:

它首先是当前模型与工具协作所使用的运行时上下文。

里面可能同时有:

text
从 MySQL 读取的 Chat History
+
当前 user message
+
当前 assistant tool_calls
+
当前 tool results
+
最终 assistant answer

它包含轨迹相关信息,但不等于只包含轨迹,也不等于一个完整的日志系统。

19.4 三者的关系

对象 主要内容 当前保存位置 请求结束后
Chat History 原始用户消息、最终助手消息 MySQL messages 表 保留
Runtime Context 历史 + 本轮输入 + 调用 + 结果 + 最终回答 Python list / RAM 对象可被释放
Agent Trace 步数、动作、参数、结果、错误等执行证据 终端输出与运行时上下文的一部分 未单独持久化,可能丢失
Long-term Memory 提炼后的长期信息 本阶段未实现 不应标记已完成

“请求结束后对象可被释放”并不意味着所有相关日志会立刻从屏幕消失,也不意味着垃圾回收发生在固定一刻;这里强调的是它没有被设计成可重启恢复的持久化记录。

19.5 为什么本阶段不把全部 Trace 当成下一轮聊天历史

一次用户交流可能只新增两条可见消息,但执行中可能有很多次模型和工具往返。

全部直接当普通历史回放,会带来:

  • 上下文更长,可能增加输入 token 与延迟;
  • 工具细节挤占当前任务需要的内容;
  • 必须保留完整调用与结果配对;
  • 普通用户对话和内部记录混在同一种读取流程中。

更准确的结论是:

本阶段采用“对话历史持久化、执行轨迹暂存”的设计;以后若持久化 Trace,可以单独设计读取和保留策略。

不是说工具轨迹没有价值,也不是说任何系统都不应该存工具消息。数据库存储量与模型 token 消耗也是两回事:只有再次送入模型上下文的内容,才会直接影响该次输入 token。

19.6 Trace 对排查有什么价值

若最终答案不对,可沿轨迹问:

text
模型选对工具了吗?
→ name

参数正确吗?
→ arguments

工具真执行了吗?
→ 工具日志 / 返回值

工具自己报错了吗?
→ exception / error result

结果是否回传了?
→ role=tool + tool_call_id

模型是否再次读取了?
→ 下一轮 Step / 模型调用

最后是否提前结束?
→ return / break 所在缩进

这正是本人“没有出现 Step 2”的错误能够被定位的原因。


20. 真实错误与排查过程

20.1 总原则:先看程序走到哪一层

text
文件能否找到?
→ 模块能否导入?
→ 配置能否读到?
→ 网络能否连接?
→ SQL / 模型请求能否执行?
→ 返回结构能否解析?
→ 工具能否执行?
→ 结果能否回传?
→ 循环能否继续?
→ 数据能否保存?
→ 界面能否正确显示?

错误文字提供线索,但必须与上下文日志一起看。不能只看到“最大步数”几个字,就认定一定真的执行了 10 轮。

20.2 MySQL 一直显示 ->

当时情况一:没有英文分号。

text
SHOW DATABASES
->

处理:补一个英文分号,完成当前 SQL。

当时情况二:打成中文分号。

中文 ; 不被识别为正确结束符,还可能已经混入语句。处理:用 \c 取消当前输入,再重新输入完整语句。

这两个场景不同,不应不加区分地一直补分号。

20.3 message 与 messages、conversion 与 conversation

当时出现过:

  • 表叫 messages,输入却写成 message。
  • 字段应叫 conversation_id,实际写成 conversion_id。

排查时先:

sql
SHOW TABLES;
DESC conversations;
DESC messages;

确认实际名称。Python 变量、SQL 列名、HTTP 字段之间的拼写必须按各自接口对应,不能靠相似名字自动关联。

20.4 中文逗号导致 ERROR 1064

错误结构示意:

text
VALUES ('abc123', 'assistant', '你好')

这里分隔值的中文逗号 , 是 SQL 结构错误。

正确:

sql
VALUES ('abc123', 'assistant', '你好');

而:

sql
VALUES ('abc123', 'assistant', '好的,我记住了');

字符串内的中文逗号没有问题。

20.5 No database selected

实际错误:

text
ERROR 1046 (3D000): No database selected

原因:新 MySQL 会话没有选择数据库。

检查与处理:

sql
SELECT DATABASE();
USE agent_demo;
SELECT DATABASE();

USE 影响当前连接。上一次窗口选过数据库,不代表新窗口自动继承。

20.6 Incorrect string value:写入失败与显示乱码要分开

当时手工输入中文出现:

text
ERROR 1366 (HY000): Incorrect string value:
'\xCE\xD2\xB5\xC4...'
for column 'content'

这个错误发生在写入阶段,含义是数据库无法按当前字符集设置正确处理传入内容。

当时的排查线索:

text
Windows 中文 CMD 可能使用 GBK / CP936
→ 客户端发送相应编码的字节
→ 连接却按其他字符集解释
→ 写入转换失败

课程曾针对当时的 CMD 输入环境使用:

sql
SET NAMES gbk;

这个操作只是告诉服务端“当前连接传输的字符使用 gbk”。它不是把整个数据库都改成 GBK,更不是以后所有连接都应该改成 GBK。

20.7 为什么后来 Python 写入正常,MySQL 窗口却显示乱码

Python 连接使用:

python
charset="utf8mb4"

另一边的 MySQL 命令行是独立连接,终端又可能使用不同代码页:

text
Python / PyMySQL
→ utf8mb4 连接
→ MySQL 中正确保存中文

MySQL 命令行
→ 返回 UTF-8 字节
→ CMD 按另一种编码解释
→ 显示成乱码

所以两条通道必须分开检查。

**字符集不同并不必然有错。**只要数据库知道输入和输出的实际编码,就可以进行正确转换。真正的问题通常是“实际字节编码”和“声明或显示时使用的编码”不一致。

20.8 推荐的诊断顺序

第一步:先通过 Python 读同一条数据。

python
from memory import get_messages

print(get_messages("new999"))

如果 Python 可以正常读到“这是一个新的会话”,而 MySQL 命令行显示错误,证据更支持“显示或客户端连接配置问题”。

第二步:查看 MySQL 连接字符集。

sql
SHOW VARIABLES LIKE 'character_set%';

重点关注:

变量 当前排查意义
character_set_client 服务端如何理解客户端发送的语句
character_set_connection 连接处理使用的字符集
character_set_results 返回结果采用的字符集
character_set_database 当前数据库的默认字符集
character_set_server 服务器默认字符集

数据库默认字符集不等于每一个已有列的实际设置。必要时继续:

sql
SHOW CREATE TABLE messages;
SHOW FULL COLUMNS FROM messages;

第三步:让终端与 MySQL 客户端按同一套编码工作。

以下操作对应课堂里的 Windows CMD 路径。先退出 MySQL:

text
exit

回到 CMD:

cmd
chcp 65001
mysql --default-character-set=utf8mb4 -u root -p

重新进入后:

sql
USE agent_demo;

SELECT role, content
FROM messages
WHERE conversation_id = 'new999'
ORDER BY id ASC;

如果实际 mysql.exe 不在 PATH,需要使用已经确认的安装路径。这组命令里的 chcp 不是 SQL,不能输入在 mysql> 中。

20.9 为什么 SET NAMES utf8mb4 后还可能乱码

sql
SET NAMES utf8mb4;

调整的是当前 MySQL 会话的客户端、连接与结果字符集;不会自动替 Windows CMD 修改显示编码。MySQL 连接字符集说明

因此可能出现:

text
MySQL 连接设置正确
但终端显示仍不匹配

官方链接用于核对连接字符集原理,不是将原项目升级到 MySQL 8.4。原项目操作记录仍是 MySQL 5.5.11 环境。

20.10 为什么不能一看到乱码就删除数据

在本人的过程里,后来中文能够正常显示,说明至少相应记录不需要通过删除重写才能恢复可读。

如果数据已经以错误文本真正存入表中,改显示编码也不会凭空恢复原文;应先保留现有数据并检查来源。对“显示错了”和“存储内容已经变坏”做出区分,再决定下一步。

**实际结果:**new999 的中文后来显示正常,agent001 阶段也曾再次出现终端乱码,最终截图已显示正常中文。

20.11 Python 找不到临时测试文件

实际命令曾写为:

cmd
python linshitest

但文件叫:

text
linshitest.py

正确:

cmd
python linshitest.py

报错发生在“Python 打开脚本文件”阶段,尚未运行 memory.py,更不是 SQL 执行失败。

20.12 ModuleNotFoundError: No module named 'tools'

实际报错:

text
from tools import calculator
ModuleNotFoundError: No module named 'tools'

应检查:

  1. 项目里是否真的创建了 tools.py;
  2. 名称是否误写为 tool.py;
  3. Windows 是否隐藏扩展名,实际变成 tools.py.txt;
  4. 文件是否在合适的模块搜索路径中;
  5. 是否运行了预期项目里的脚本与解释器。

本项目里的 tools 指本地模块,不是看到错误就去安装一个同名第三方包。

修正后,原对话继续完成了 calculator 结果 96 的验证。

20.13 APIConnectionError 与 TLS EOF

实际错误链包含:

text
openai.APIConnectionError: Connection error.
httpx.ConnectError:
EOF occurred in violation of protocol (_ssl.c:1125)

调用栈中还有:

text
httpcore\_sync\http_proxy.py

这些信息支持“请求经过代理,连接或 TLS 握手被中断”的排查方向,但仅凭这些行不能证明所有同类错误都由 VPN 引起。

应该先检查:

text
BASE_URL 是否为之前已跑通的服务地址
→ 代理或 VPN 是否影响这条连接
→ HTTP_PROXY / HTTPS_PROXY / ALL_PROXY 等环境配置
→ 网络是否可达
→ 同一客户端的最小普通模型请求是否成功

不要先改 calculator 或 Tool Schema,因为网络连接还没成功时,远端模型尚未处理工具内容。也不应通过关闭证书验证来掩盖原因。

原对话之后成功返回了 calculator 的 Tool Call,说明该阶段连接恢复并继续推进;未记录的具体网络调整不补写成事实。

20.14 “达到最大步数”最后查出是缩进

第一次只有一行结果:

text
最终回答:Agent达到最大执行步数,任务未完成

原回答一开始推测模型连续调用工具,可能真的跑满了 10 轮。但只有这句话时,证据不足。

增加日志后实际看到:

text
当前Step: 1
工具执行结果:96
工具执行结果:24.0
最终回答:Agent达到最大执行步数,任务未完成

关键证据:没有 Step 2。

这说明程序第一轮完成工具后提前返回。结合修改后恢复正常,问题定位到超限 return 的缩进。

错误结构示意:

python
def wrong_loop_example():
    step = 0
    while step < 10:
        step += 1
        # 本轮处理……
        return "Agent达到最大执行步数,任务未完成"

这会在第一轮结束函数,和 step 是否达到 10 没有关系。

正确结构:

python
def correct_loop_shape():
    step = 0
    while step < 10:
        step += 1
        # 本轮处理;正常完成时在这里 return 答案。

    return "Agent达到最大执行步数,任务未完成"

这两段只演示控制流,不是替代完整 Agent 的实现。

20.15 修复后的真实终端结果

以下保留关键字段,省略冗长 SDK 对象与随机调用 id:

text
当前Step: 1
模型 content:
模型 tool_calls:[两个 calculator 调用]

模型选择工具:calculator
模型生成参数:{'a': 12, 'b': 8, 'operation': 'multiply'}
工具执行结果:96

模型选择工具:calculator
模型生成参数:{'a': 96, 'b': 4, 'operation': 'divide'}
工具执行结果:24.0

当前Step: 2
模型 content:最终答案是 24。
模型 tool_calls:None
最终回答:最终答案是 24。

这次不仅“结果数字正确”,还证实执行完工具后能继续回到模型,直到产生最终回答。

20.16 数学文本里的反斜杠不一定是乱码

本人最终回答包含:

text
$ 12 \times 8 = 96 $
$ 96 \div 4 = 24.0 $
**24.0**

这些分别是数学标记或 Markdown 加粗标记。MySQL CLI 和 Swagger JSON 原文不一定把它们渲染成公式或加粗。

同样:

  • JSON 中 \n 是换行的转义表示;
  • JSON 中 \\ 表示一个字面反斜杠;
  • **24.0** 是 Markdown 文本,不是数值损坏。

要把格式标记、JSON 转义、中文编码错误区分开。


21. 本人实际跑通的关键结果

21.1 验证记录总表

阶段 可确认的结果 能说明什么
MySQL 登录 原记录显示版本 5.5.11 已能连接数据库服务并执行查询
建库建表 agent_demo 与两张表创建成功 聊天记录有了实际存储结构
手工插入 abc123 的消息能 SELECT 出来 SQL 读写基础跑通
Python 连接 ('5.5.11',) Python → 驱动 → Server → Python 返回链路成功
读取历史 role/content tuple 转为 list + dict 历史可用于模型上下文
写入与创建会话 new999 同时出现在两张表 ensure_conversation 与 add_message 协同
中文显示 “这是一个新的会话”正常 对应记录的显示问题解决
持久化 Chatbot persist001 记住“岭西” MySQL 历史接入 FastAPI 与 Qwen
第一次 Function Calling 返回 calculator 与 JSON 参数 模型确实提出工具调用
执行工具 12 × 8 得到 96 Python 真正执行了函数
回传结果 模型回答 12 乘以 8 等于 96 工具输出重新进入模型上下文
多步计算 得到 96、24.0,最终回答 24 工具执行与最终回答链路成功
模块化修复 Step 1 两个调用,Step 2 最终回答 Loop 没有第一轮提前返回
最终 HTTP 整合 agent001 计算请求返回 200 FastAPI 能调用 Agent
最终 MySQL 检查 只显示 user 与 assistant 两行 本轮持久化遵守当前保存策略
同会话追问 回答刚才结果是 24.0 Agent 使用了持久化聊天历史

21.2 第一张关键结果:agent001 计算请求

本次重新查看的 Swagger 截图中,用户输入为:

json
{
  "conversation_id": "agent001",
  "message": "请使用计算机,先计算12乘以8,再把结果除以4。"
}

这里保留截图中的“计算机”原文;教学与复现示例通常写“计算器”。

返回状态:

text
200

回答展示了:

text
12 × 8 = 96
96 ÷ 4 = 24.0
最终结果是 24.0

正文含换行、公式标记与 Markdown 加粗。本节为了易读转写了显示格式,没有把响应原文中的所有转义逐字复制。

21.3 第二张关键结果:MySQL 只保存两条消息

截图执行:

sql
SELECT role, content
FROM messages
WHERE conversation_id = 'agent001'
ORDER BY id ASC;

结果中:

text
role
----
user
assistant

2 rows in set

说明在该截图时间点,首次请求只持久化了两条消息。assistant 正文包含计算步骤,不是独立 tool 记录。

这是时间点快照:后面再发送追问,并成功保存以后,消息总数可以继续增加,不能永远要求 agent001 只有两行。

21.4 第三张关键结果:追问能记得 24.0

Swagger 追问:

json
{
  "conversation_id": "agent001",
  "message": "刚才最终算出来是多少?"
}

返回状态为 200,响应为:

json
{
  "answer": "刚才最终算出来是 **24.0**。"
}

证明系统能在后续请求中使用 agent001 的聊天背景。

结合 main.py 的读取路径与数据库记录,可以说明这条应用流程使用了 MySQL Chat History;单看模型一句“我记得”不足以证明持久化,需要和代码及数据库证据一起看。

21.5 哪些测试不能补写成已经完成

以下属于课程建议或后续检查,不在现有记录中当作独立实测结果:

  • 最终 Agent 整合后,完整停止并重启 Uvicorn 的单独日志;
  • 除零处理在真实 Qwen 上的完整成功截图;
  • 真正耗尽 max_steps 的完整 10 轮日志;
  • 多个并发 HTTP 请求的会话一致性;
  • 数据库事务故障恢复;
  • 结构化 Agent Trace 持久化;
  • 用户级长期记忆提炼与检索。

其中“最大步数”那次错误后来被缩进问题解释,不能反过来作为已经测试过循环超限的证据。


22. 从零复现与验收顺序

22.1 不要一次把所有层都接上再找问题

推荐顺序:

text
数据库可登录
→ 手工 SQL
→ Python 数据库连接
→ get_messages
→ add_message / ensure_conversation
→ 单独 calculator
→ 模型提出 Tool Call
→ Python 解析并执行
→ Tool Result 回传
→ Agent Loop
→ 模块化脚本
→ FastAPI 整合
→ MySQL 持久化与后续追问

每一步知道输入与预期输出,失败时就能缩小范围。

22.2 先检查配置和文件

  • 使用原项目已跑通的虚拟环境和依赖。
  • .env 中数据库与模型配置正确,字符串没有留占位内容。
  • config.py 中环境变量名与 .env 一致。
  • tools.py、agent.py、memory.py、db.py、main.py 位于正确目录。
  • 工具 Schema 的 name 与 Registry key 一致。
  • 请求模型当前使用 message 还是 question 已确认。
  • 不把密码、API Key 粘贴到测试截图或笔记中。

22.3 数据库读写检查

sql
USE agent_demo;
SHOW TABLES;
DESC conversations;
DESC messages;

SELECT role, content
FROM messages
WHERE conversation_id = 'new999'
ORDER BY id ASC;

再运行:

cmd
python test_db.py
python linshitest.py

若只验证读取,临时测试不要夹带 add_message,以免每次运行都新增数据。

22.4 Agent 的正常路径检查

cmd
python tool_call_test.py

应观察到:

text
至少一次模型返回 tool_calls
→ 函数实际执行
→ 对应 tool result 被追加
→ 后续模型调用
→ 没有 tool_calls 的最终回答

模型每次可能选择不同的调用分组或措辞,所以“必须恰好两轮”不是所有运行的固定验收条件。本人成功日志是两轮,但正确的三轮依赖式调用也可以成立。

22.5 单独检查错误路径

以后可以依次测试:

测试 要检查什么
直接调用 calculator 除以 0 函数确实抛异常
Agent 使用除零输入 若模型选择工具,错误能作为 tool result 返回
模拟未知工具名 Registry 分支返回明确错误
使用很小 max_steps 能停止,不无限循环
模拟非法 JSON 明确认识课堂版会向上抛解析异常

只给模型一句“除以 0”,不保证它一定会调用工具;它可能直接解释。因此验证工具异常处理时,确定性地直接调用函数或模拟 Tool Call,更容易知道测到了哪条路径。

22.6 启动 HTTP 服务

cmd
uvicorn main:app --reload

打开:

本机 Swagger 文档 http://127.0.0.1:8000/docs

服务正常启动时应有类似:

text
Application startup complete.

如果导入模块或读取配置时就报错,路由里的 try/except 尚未开始执行,不能指望它捕获启动错误。

22.7 最终三步验收

第一步:计算。

用一个没有历史干扰的新 conversation_id:

json
{
  "conversation_id": "review_agent_001",
  "message": "请使用计算器,先计算12乘以8,再把结果除以4。"
}

第二步:查数据库。

sql
SELECT role, content
FROM messages
WHERE conversation_id = 'review_agent_001'
ORDER BY id ASC;

首次成功请求后应只有当前 user 与最终 assistant 两条。

第三步:同会话追问。

json
{
  "conversation_id": "review_agent_001",
  "message": "刚才最终算出来是多少?"
}

应能利用历史回答 24 或 24.0。

进一步可停止并重启 Uvicorn,再问一次,单独验证跨进程恢复。不要为了复现而删除旧学习记录,换一个新的 conversation_id 即可。


23. 常见误区与学习回答校准

曾经容易这样理解 更准确的理解
一个 dict 是一整轮对话 一个 dict 通常是一条消息;一轮可能至少两个
dict.get(key, []) 会创建这个 key 它只是查找并返回默认值,不会自动写入字典
get_messages 查不到就创建会话 当前读取函数返回空列表;创建由 ensure_conversation 负责
MySQL 保存了内容,模型就永久记住 应用要读取并再次传给模型;模型参数没有因此变化
Chat History 存 RAM,Long-term Memory 存数据库 应按内容与用途区分,二者都可持久化
users 中保存所有 conversation_id 通常让 conversations 保存所属 user_id
字段叫 conversation_id 就自动有外键 名称不创建约束;课堂表没有声明外键
ensure_conversation 防止重复消息 它只避免重复创建会话,add_message 仍会追加
调用成功后保存,两条消息就一定原子写入 两次独立 commit 仍可能只完成一次
Observe 是获取模型输出 主要是观察工具或环境的执行结果
Flow 只能调用一次工具 Flow 可多步骤多工具,重点在预定义编排
LLM 定义 calculator 有哪些参数 程序员定义参数规则,模型决定本次参数值
LLM 返回 calculator 就已执行 Python 还要查找函数并调用
Schema 就是工具实现 Schema 是说明,真实函数负责动作
Schema 会自动防住所有错误参数 仍要在执行侧解析、校验或捕获错误
content 为空就代表模型失败 可能正在返回 tool_calls
arguments 看着像 dict 就能直接 ** 展开 SDK 中通常先是 JSON 字符串,需 json.loads
print(result) 就把结果给 Qwen print 只给终端看,需追加 tool message 再请求模型
tool_call_id 就是工具名字 它标识某一次调用,同一工具可有多个不同 id
只执行 tool_calls[0] 就够 一轮可能有多个调用,要完整处理
while 和 for 都是在算工具调用次数 while 管决策轮次,for 管本轮调用列表
多 tool_calls 意味着 Python 并发执行 当前 for 是顺序执行;协议分组与执行并发不同
第二个调用参数 96 一定来自已执行的第一个工具 若同一响应同时生成两个调用,模型在真实结果前就写出了 96
Registry value 应写 calculator() 保存函数对象 calculator,执行时再加括号
**arguments 只是固定三个参数的简写 它通用地把字典展开成关键字参数
dict.get 会告诉我具体错误 它仅返回默认值;错误提示由应用编写
max_steps=10 表示最多 10 次工具 限制外层轮次,不直接限制工具总数
有 max_steps 就不会卡住 它不自动处理单次请求或工具超时
except 捕获工具执行后,JSON 就不会出错 json.loads 若在 try 外仍会向上抛异常
return 把值返回给 LLM return 返回给当前 Python 函数调用者
return None 会结束整个进程 只结束当前函数并返回 None
超限提示说明真的执行了 10 轮 还可能是 return 放错位置,需要看 step 日志
run_agent 的 messages 是纯日志 它是历史与本轮执行信息组成的上下文
MySQL 出现 96 就说明存了 Trace 96 也可出现在最终回答正文,要看消息结构与角色
工具失败就一定立即结束 Agent 可把错误回传模型,但模型不保证能修复
200 代表任务必然成功 当前超限提示也可能作为普通字符串返回 200
MySQL 中文乱码就说明数据全坏了 先区分写入失败、存储内容和客户端显示
SET NAMES 会修改整个数据库编码 它主要调整当前连接字符集,不替终端改代码页
终端显示 \times 或 ** 是乱码 可能只是未渲染的公式与 Markdown 标记

24. 关键术语与复习自测

24.1 术语表

术语 中文理解 本项目中的位置
RAM 进程运行时内存 Python 字典、运行时 messages
Persistence 持久化 MySQL 保存已提交记录
Chat Memory 聊天记忆机制 读写历史并传入模型
Chat History 聊天历史原文 messages 表中的 user / assistant
Long-term Memory 长期有用信息的记忆机制 本阶段只建立概念
Conversation 一整段会话 abc123、persist001、agent001
Message 一条消息 role + content
Primary Key 主键 表中的 id
Foreign Key 外键约束 课堂讲过概念,实际表暂未声明
One-to-Many 一对多 一段会话含多条消息
Driver 数据库驱动 PyMySQL
Connection 连接 get_connection 返回的对象
Cursor SQL 执行与取结果对象 connection.cursor()
Transaction 事务 commit / rollback 的工作范围
Parameterized SQL 参数化查询 execute(sql, params)
Tool 可执行能力 calculator
Tool Schema 工具说明结构 calculator_tool
Function Calling 结构化函数调用机制 模型输出 name 与 arguments
Tool Call 一次调用请求 message.tool_calls 中的元素
Arguments 本次调用参数 JSON 解析后的 dict
Tool Result 工具执行结果 role=tool 的消息
Observation 动作结果反馈 96、24.0 或错误文本
Tool Registry 工具名到函数的映射 tool_registry
Runtime 执行环境与调度过程 agent.py
Agent Loop 模型与工具反复配合的循环 run_agent
Step 一轮 Agent 决策循环 外层 while 的 step
max_steps 循环轮次上限 默认 10
Agent Trace 执行轨迹 调用、结果、错误、步数等
Runtime Context 当前执行上下文 历史 + 本轮消息 + 工具交互
Workflow 预先编排的工作流 与动态 Loop 对比理解
HTTP Request Body 请求正文 conversation_id + message
Swagger UI 接口交互测试网页 /docs
Charset 字符集设置 utf8mb4、连接与终端编码
Module Python 模块 tools.py、agent.py 等
Decoupling 解耦 调用者不依赖底层存储细节

24.2 用自己的话回答这 15 个问题

  1. 为什么把 memory.py 的 dict 换成 MySQL 后,main.py 的读写调用可以基本不变?
  2. messages.conversation_id 与 conversations.conversation_id 是怎样关联的?现在是否有数据库外键?
  3. SQL 查出的 tuple 为什么要转成 list + dict?
  4. (conversation_id,) 的逗号有什么作用?
  5. ensure_conversation 与 add_message 在重复执行时分别会发生什么?
  6. 为什么数据库持久化不等于模型被训练?
  7. Tool Schema、Tool Registry 和 calculator 函数分别给谁使用?
  8. JSON arguments、Python arguments dict 和 **arguments 分别是什么?
  9. 为什么要先追加 assistant 的 tool_calls,再追加 role=tool?
  10. 外层 while 与内层 for 分别在循环什么?
  11. 为什么 Step 1 两个调用、Step 2 最终回答,总共不是四个 Agent Step?
  12. 为什么没有 Step 2 是排查提前 return 的关键证据?
  13. Tool Result 回传模型与 run_agent 的 return 有什么区别?
  14. 为什么不能遍历 run_agent 执行后的 messages 全部写回 MySQL?
  15. 为什么 SET NAMES utf8mb4 成功后,CMD 仍可能显示乱码?

24.3 参考答案要点

  1. 对外函数接口稳定,底层读写从 dict 改为 SQL。
  2. 每条消息记录所属会话标识;课堂表未声明外键约束。
  3. 存储结果结构与模型消息协议不同,需转换字段与容器结构。
  4. 创建单元素 tuple;没有逗号通常仍是原字符串。
  5. 已有会话不重复创建;消息每调用一次仍追加一条。
  6. 数据要再次读取并提供给模型,没有自动修改模型参数。
  7. Schema 给模型描述能力;Registry 给 Python 查函数;函数真正执行。
  8. 先是文本,再经 json.loads 成为 dict,最后按参数名展开调用。
  9. 保留调用与结果的协议关联,并用 tool_call_id 配对。
  10. while 控制下一次模型决策,for 处理当前响应中的全部调用。
  11. step 按外层轮次计数,工具数量不直接增加该 step。
  12. 说明程序在第一轮后离开了循环或函数,提示文本不能证明跑满上限。
  13. Tool Result 经下一次请求给模型;return 给调用 run_agent 的 Python 代码。
  14. 会重复保存旧历史,也会混入内部结构;只保存本轮两条可见消息。
  15. MySQL 连接字符集与终端显示代码页是两个设置层次。

24.4 最应该能独立画出的数据流

text
客户端发送 conversation_id + message
                  ↓
FastAPI 校验请求
                  ↓
get_messages(conversation_id)
                  ↓
MySQL SELECT → 按顺序恢复历史
                  ↓
追加当前 user message
                  ↓
run_agent(messages)
                  ↓
Qwen 读取上下文与 Tool Schema
                  ↓
        有 tool_calls 吗?
          ├─ 否 → 返回最终文本
          └─ 是
              ↓
          解析 JSON arguments
              ↓
          Registry 查找函数
              ↓
          function(**arguments)
              ↓
          成功结果或错误 Observation
              ↓
          追加对应 tool message
              ↓
          回到 Qwen,受 max_steps 限制
                  ↓
main.py 得到 answer
                  ↓
只 INSERT 本轮 user + 最终 assistant
                  ↓
FastAPI 返回 JSON

理解这张图,就能把 Python 基础、数据库、模型、工具和 HTTP 服务连成一个整体。


25. 当前边界与资料依据

25.1 本阶段已经形成的能力

text
MySQL 持久化 Chat History
+
FastAPI 请求入口
+
Qwen 工具调用
+
Python 工具执行
+
Tool Registry
+
多轮 Agent Loop
+
轮次上限与基础工具异常处理
+
模块化组织

这些能力已经在本人学习过程中组合运行,并由计算与后续追问验证。

25.2 已讨论但尚未完成的能力

以下只作为复习时的边界提醒,不展开下一课:

  • 提炼、更新与检索 Long-term Memory;
  • 结构化持久化 Agent Trace;
  • 把模型调用进一步统一抽到 model.py;
  • 同一会话并发请求的顺序控制;
  • 一轮对话的原子写入与消息幂等;
  • 完整的参数校验、失败状态和超时策略;
  • 多用户身份与会话访问权限;
  • RAG、复杂外部工具和部署。

学习进度截止第 53 课;第 54 课的教学内容未纳入本笔记。

25.3 本次整理对原讲解作出的必要校准

为了避免以后复习时把简化说法当成严格结论,笔记明确了:

  1. “先调用后保存”降低模型失败导致的半轮写入,但不自动等于数据库事务。
  2. 原 messages 表没有外键,逻辑关联不能写成强制约束已实现。
  3. 最终实际日志是同轮两个 Tool Call,不是每一个工具结果都先被模型观察后再生成下一组参数。
  4. “达到最大步数”的文字不能证明执行了 10 轮;那次已定位并修复为缩进问题。
  5. 最终 assistant 正文可以包含 96、24 和计算步骤,不能把这些数字直接认定为内部 Trace。
  6. 长期保存原始 Chat History 与构建 Long-term Memory 是不同工作。
  7. 当前 run_agent 使用的 messages 包含历史与执行信息,并非纯日志。
  8. 原课堂代码的失败状态、参数解析和并发边界仍然保留,没有伪装成已升级版本。

25.4 原始学习资料

主要依据:分支 · Python基础学习指南(内部学习记录)。

本次已回读到上一次笔记的整理要求,并向后覆盖第 35—53 课相关内容;最后三张 agent001 截图已重新查看,终端中的模块化成功日志也已核对。

原始截图没有以临时路径嵌入本文件,关键内容已转写在第 21 节,因此这份 Markdown 可以独立保存,不依赖临时附件目录。

25.5 技术核对资料

以下资料用于核对少量协议与基础行为,笔记主体仍来自本人课程过程,并未改变原项目技术路线:

25.6 代码整理与验证说明

正文带 # file: 文件名 的代码块组成可对照的最小模块集合;其中 config.py 补充了明确的 .env 路径,数据库示例补充了资源关闭与回滚,测试脚本补充了入口保护。agent.py 与 main.py 保留课堂核心流程和已说明的局限。

本次只进行本地语法、格式及模拟依赖下的控制流检查。模拟检查用于确认笔记代码的消息配对、循环退出、错误回传与保存范围,不代表重新调用了 Qwen 或重新验证了个人 MySQL 服务。

这一阶段最值得记住的能力:不仅能说出“Agent 有 Memory 和 Tools”,而且能沿着一次真实请求,解释历史在哪里读出、工具由谁执行、结果怎样交回模型,以及最终哪些消息被保存。