【Agent开发】第一阶段:基石构建 —— 容器化交互与 Prompt 工程
本文介绍了在Docker环境中构建基础AI Agent的开发流程。首先强调理解底层数据流的重要性,提出Agent本质是一个包含观察、思考、行动、结果的循环过程。详细说明了Docker网络配置要点,包括服务名作为内部域名的使用方式。提供了虚拟环境配置指南,推荐使用pyproject.toml管理依赖。实战部分展示了如何定义工具集(如获取日期时间函数)并与LLM交互,通过纯Python实现一个能调用本
【Agent开发】第一阶段:基石构建 —— 容器化交互与 Prompt 工程 – pd的AI Agent开发笔记
环境配置:当前环境是基于WSL2 + Ubuntu 24.04 + Docker Desktop构建的云原生开发平台,所有服务(MySQL、Redis、Qwen)均以独立容器形式运行并通过Docker Compose统一编排。如何配置请参考我的博客 WSL2 + Ubuntu 24.04 + Docker Desktop 配置双内核环境
第一讲:打通任督二脉 —— 在 Docker 网络中唤醒你的第一个 Agent
本讲目标:不依赖任何重型框架,用纯 Python + requests 库,在你的 WSL2 + Docker 环境中,实现一个能“思考”并“调用本地时间工具”的微型 Agent。
1. 为什么我们要“徒手造轮子”?
很多教程一上来就让你 pip install langchain,然后配置一堆复杂的 Chain。但对于 AI Agent 开发,理解底层数据流比学会调用 API 更重要。
Agent 的本质其实就是一个 while 循环:
- 观察 (Observation):用户说了什么?
- 思考 (Thought):我需要做什么?(查数据库?查时间?还是直接回答?)
- 行动 (Action):调用工具。
- 结果 (Result):工具返回了什么?
- 重复:直到问题解决。
今天,我们就把这个循环手写出来。
2. 环境准备:Docker 网络的“秘密通道”
在我的架构中,Python 代码(无论是跑在宿主机还是另一个容器)要访问 Qwen、Redis 和 MySQL,绝对不能使用 localhost 或 127.0.0.1。
🧠 核心概念:Docker Compose DNS
当你运行 docker-compose up 时,Docker 会自动创建一个内部网络。在这个网络里,服务名就是域名。
- ❌ 错误写法:http://localhost:8000/v1/chat/completions (这是你宿主机的端口,容器内部访问不到)
- ✅ 正确写法:http://qwen-local:8000/v1/chat/completions (假设你的 compose 文件里大模型服务名叫 qwen-local)
注意:这是一般情况,但是我的使用NAT模式 转发到我localhost:7575 端口上,因为我的8000端口跑着我的博客,所以如果是照着我的教程配置的环境直接访问下面这个端口即可
- ✅ 我的教程:http://localhost:7575/v1/chat/completions
🛠️ 第一步:确认服务名
请先在你的项目根目录打开终端,运行:
cd ~/ai-stack
# 可以顺便确认服务名称
docker-compose ps
# 如果没启动qwen
docker-compose up -d qwen
🛠️ 第二步:虚拟环境配置指南
在windows下,先去python官网下载安装python 3.12.8
创建虚拟环境:
# 在项目根目录下创建虚拟环境
python -m venv .venv
# 激活虚拟环境
.venv\Scripts\activate
# 要是装在ubuntu下,则使用下面的命令
source .venv/bin/activate
在项目根目录下创建名为 pyproject.toml 的文件,填入以下内容:
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "docker-agent-lab"
version = "0.1.0"
description = "基于 Docker + Qwen 的 AI Agent 学习项目"
requires-python = ">=3.12"
dependencies = [
"requests>=2.31.0",
"python-dotenv>=1.0.0",
"ipython>=8.20.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"black>=24.0.0",
]
[tool.setuptools]
# 禁用自动发现,防止误把 img, LangGraph 等文件夹当作包
packages = []
💡 字段解析:
- [build-system]: 告诉 pip 如何构建这个项目(这里用最通用的 setuptools)。
- [project]: 核心配置。dependencies 列表就是你的“新 requirements.txt”。
- requires-python = “>=3.12”: 强制锁定 Python 版本,防止在低版本环境误运行。
安装项目依赖
pip install --upgrade pip
pip install -e .
pip install -e ".[dev]"
未来新加入依赖时怎么处理?
- 第一步:安装包并验证
先正常安装,确保代码能跑通:
pip install redis
此时去写代码,验证功能正常。
- 第二步:同步更新 pyproject.toml
打开 pyproject.toml,手动将新包添加到 dependencies 列表中。
dependencies = [
"requests>=2.31.0",
"python-dotenv>=1.0.0",
"redis>=5.0.0", # <--- 新增
"langchain-core>=0.1.0" # <--- 新增
]
项目结构
AIAgent开发/
├── pyproject.toml
├── .venv/
├── src/ <-- 新建 src 目录
│ └── my_agent/ <-- 把你的代码移到这里,并确保有 __init__.py
│ ├── __init__.py
│ └── agent_v1.py
├── img/ <-- 这些杂项文件夹现在不会被扫描了
├── LangGraph/
└── tests/
3. 实战编码:构建 Mini-Agent
我们在src目录创建一个 src\Mini-Agent\agent_v1.py, 暂时将定义工具,封装LLN调用,实现ReAct循环写在一个文件里.
import json
import re
import requests
from datetime import datetime
from typing import Optional, Dict, Any
# --- 1. 定义工具集 ---
def get_current_date(*args, **kwargs) -> str:
"""获取当前的日期(包含年月日和星期几)"""
now = datetime.now()
# %Y-%m-%d: 2026-02-26
# %A: 完整的星期名称 (Thursday), %a: 缩写 (Thu)
# 为了保险,我们同时返回中文星期(如果系统 locale 支持)或英文
# 这里为了通用性,返回明确的格式
date_str = now.strftime("%Y-%m-%d")
weekday_str = now.strftime("%A") # 例如 "Thursday"
# 简单映射到中文,防止模型对英文星期反应迟钝(可选,视模型语言能力强弱)
weekday_map = {
"Monday": "星期一", "Tuesday": "星期二", "Wednesday": "星期三",
"Thursday": "星期四", "Friday": "星期五", "Saturday": "星期六", "Sunday": "星期日"
}
cn_weekday = weekday_map.get(weekday_str, weekday_str)
return f"日期:{date_str}, 星期:{cn_weekday} ({weekday_str})"
def get_current_time(*args, **kwargs) -> str:
"""获取当前的具体时间(时:分:秒)"""
now = datetime.now()
return f"时间:{now.strftime('%H:%M:%S')}"
# 更新注册表
TOOLS_REGISTRY = {
"get_current_date": {
"description": "当用户询问日期、今天几号、今天是星期几时使用。不需要参数。",
"function": get_current_date
},
"get_current_time": {
"description": "当用户询问具体几点、当前时刻时使用。不需要参数。",
"function": get_current_time
}
}
# 更新工具定义 (Schema)
TOOLS_DEFINITION = [
{
"type": "function",
"function": {
"name": "get_current_date",
"description": "获取当前的日期(包含年月日和星期几)",
"parameters": {"type": "object", "properties": {}, "required": []}
}
},
{
"type": "function",
"function": {
"name": "get_current_time",
"description": "获取当前的具体时间(时:分:秒)",
"parameters": {"type": "object", "properties": {}, "required": []}
}
}
]
# --- 2. LLM 交互层 ---
# 检查模型名
# curl http://localhost:7575/v1/models
# "id": "qwen-3-4b", <-- 🎯 这就是你要的 model name!
MODEL_NAME = "qwen-3-4b"
LLM_API_URL = "http://localhost:7575/v1/chat/completions"
SYSTEM_PROMPT = """
你是一个智能助手。你可以使用以下工具来回答问题。
你必须严格按照 JSON 格式回复,不要包含任何 Markdown 标记(如 ```json)。
格式如下:
{
"thought": "你现在的思考过程,分析用户需要什么",
"action": "工具名称 (如果没有工具可用,填 null)",
"action_input": "工具的参数 (JSON 对象,如果没有填 {})"
}
如果不需要调用工具,直接设置 action 为 null,并在 thought 中组织最终回答。
可用工具:
"""
def call_llm(messages: list, tools_desc: str) -> Dict[str, Any]:
full_system_prompt = SYSTEM_PROMPT + tools_desc
payload = {
"model": MODEL_NAME, # 模型名需与你容器内一致
"messages": [
{"role": "system", "content": full_system_prompt},
*messages
],
"temperature": 0.05, # Agent 需要低温度以保证逻辑稳定
"stream": False
}
try:
response = requests.post(LLM_API_URL, json=payload, timeout=30)
response.raise_for_status()
data = response.json()
content = data['choices'][0]['message']['content']
# 2. 【新增】去除 Qwen 的思维链标签 (<think> ... </think>)
# 使用正则匹配 <think> 开头到 </think> 结尾的内容,并替换为空
content = re.sub(r'<think>.*?</think>', '', content, flags=re.DOTALL)
# 清理可能存在的 markdown 标记
if content.startswith("```json"):
content = content[7:]
if content.endswith("```"):
content = content[:-3]
# 3. 去除首尾空白字符
content = content.strip()
return json.loads(content.strip())
except Exception as e:
print(f"❌ LLM 调用失败: {e}")
# 如果是连接错误,提示用户检查 URL
if "Connection refused" in str(e):
print("💡 提示:请检查 LLM_API_URL 是否正确。如果在 WSL2 直接运行,尝试改用 http://localhost:<端口>")
return {"thought": "系统错误", "action": None}
# --- 3. ReAct 引擎 ---
def run_agent(user_query: str):
messages = [{"role": "user", "content": user_query}]
print(f"👤 用户: {user_query}")
max_steps = 5 # 防止死循环
step = 0
while step < max_steps:
step += 1
print(f"\n--- 🔄 第 {step} 轮思考 ---")
# 1. 请求 LLM
result = call_llm(messages, json.dumps(TOOLS_DEFINITION, ensure_ascii=False))
thought = result.get("thought", "")
action = result.get("action")
action_input = result.get("action_input", {})
print(f"🧠 思考: {thought}")
# 2. 判断是否结束
if not action:
print(f"🤖 Agent: {thought}") # 此时 thought 里通常包含最终回答
break
# 3. 执行工具
if action in TOOLS_REGISTRY:
tool_func = TOOLS_REGISTRY[action]["function"]
print(f"🛠️ 执行工具: {action}")
try:
# 简单起见,这里假设所有工具都返回字符串
observation = tool_func(**action_input)
print(f"📝 观察结果: {observation}")
# 将结果反馈给 LLM
messages.append({"role": "assistant", "content": json.dumps(result, ensure_ascii=False)})
messages.append({"role": "user", "content": f"工具 {action} 执行完毕,结果是:{observation}。请根据结果回答用户。"})
except Exception as e:
error_msg = f"工具执行出错: {str(e)}"
print(f"❌ {error_msg}")
messages.append({"role": "user", "content": error_msg})
else:
print(f"⚠️ 未知工具: {action}")
messages.append({"role": "user", "content": f"错误:找不到工具 {action}。请重试。"})
if step >= max_steps:
print("⚠️ 达到最大思考步数,停止。")
# --- 4. 启动 ---
if __name__ == "__main__":
# 测试问题
query = "现在几点了?顺便告诉我今天是星期几。"
run_agent(query)
执行结果:
👤 用户: 现在几点了?顺便告诉我今天是星期几。
--- 🔄 第 1 轮思考 ---
🧠 思考: 用户需要当前时间以及今天的星期几信息,我需要调用两个工具来获取这些数据。
🛠️ 执行工具: get_current_time
📝 观察结果: 时间:22:49:13
--- 🔄 第 2 轮思考 ---
🧠 思考: 用户需要当前时间及星期几,已通过get_current_time获取时间,需调用get_current_date获取星期几后整合回答。
🛠️ 执行工具: get_current_date
📝 观察结果: 日期:2026-02-26, 星期:星期四 (Thursday)
--- 🔄 第 3 轮思考 ---
🧠 思考: 已获取当前时间22:49:13和日期信息(2026-02-26,星期四),现在可以整合信息回答用户。
🤖 Agent: 已获取当前时间22:49:13和日期信息(2026-02-26,星期四),现在可以整合信息回答用户。
📝 第一讲核心复盘

1. 定义工具 (Tools) —— 给大脑装上“机械臂”
核心逻辑:
LLM(大模型)本身只是一个被关在黑盒子里的“大脑”,它看不见你的文件系统,算不准当前的时间,也连不上你的数据库。工具(Tools)就是连接这个大脑与现实世界的桥梁。
- 做了什么:
- 我们定义了普通的 Python 函数(如 get_current_date, get_current_time)。
- 我们将这些函数注册到一个字典 TOOLS_REGISTRY 中,方便后续通过字符串名称查找并执行。
- 我们构造了一份“工具说明书”(TOOLS_DEFINITION),用 JSON 格式告诉 LLM:“我有这些技能,它们叫什么,需要什么参数”。
- 关键点:
- 确定性 vs 概率性:日期、计算、数据库查询等需要100% 准确的任务,必须交给工具(代码)去做,绝不能让 LLM 靠“猜”(生成文本)来完成。这是消除幻觉的第一原则。
- 描述即指令:工具的 description 写得越清晰,LLM 选择工具的准确率就越高。
类比:
LLM 像一个博学的教授,但他被困在一个没有窗户的房间里。
Tools 就是你递给他的电话、计算器和互联网终端。他不能自己算数,但他知道“拿起计算器(调用工具)”就能得到答案。
2. 封装 LLM 调用 (The Brain) —— 打造标准化的“思考接口”
核心逻辑:
直接裸调 API 容易出错且难以维护。我们需要一个统一的函数,负责把“用户问题 + 工具说明书 + 历史对话”打包发给模型,并把模型的“胡言乱语”清洗成程序能读懂的结构化数据。
- 做了什么:
- 构建 Prompt:将系统指令(System Prompt)、工具列表和对话历史拼接成完整的上下文。
- 网络请求:使用 requests 发送 HTTP POST 请求到 Docker 内的 Qwen 服务(注意 localhost 与服务名的区别)。
- 清洗与解析:
- 去除 LLM 偶尔输出的 Markdown 标记(```json)。
- 关键一步:利用正则表达式去除
<thinking>思维链标签,只提取纯净的 JSON 字符串。 - 将 JSON 字符串解析为 Python 字典 (dict)。
- 关键点:
- 结构化输出:Agent 的核心在于可控。我们强制模型输出 JSON(包含 thought, action, action_input),这样代码才能判断下一步该干什么。
- 容错处理:网络波动或模型抽风导致 JSON 解析失败时,要有 try-except 捕获,防止程序直接崩溃。
类比:
这是一个翻译官 + 秘书。
它把你的自然语言需求翻译成模型能懂的 Prompt,再把模型吐出来的一堆文字(可能夹杂着想说的话)整理成一张标准的“行动工单”(JSON),交给执行程序去处理。
3. 实现 ReAct 循环 (The Loop) —— 赋予自主执行的“心脏”
核心逻辑:
这是 Agent 区别于普通聊天机器人的地方。普通机器人是 输入 -> 输出(一次性的);Agent 是 输入 -> 思考 -> 行动 -> 观察 -> 再思考 -> … -> 输出(循环的)。
- 做了什么:
- 使用 while 循环构建主流程,设置 max_steps 防止死循环。
- Step 1 思考:调用 LLM,获取它决定的 action(动作)。
- Step 2 判断:
- 如果 action 为空(或特定结束符),说明任务完成,输出最终回答,break 跳出循环。
- 如果 action 有值,进入下一步。
- Step 3 行动:根据 action 名字从 TOOLS_REGISTRY 找到对应函数并执行,得到 observation(观察结果)。
- Step 4 反馈:将“工具执行的结果”作为新的用户消息(role: user)塞回对话历史,让 LLM 基于这个新事实进行下一轮思考。
- 关键点:
- 闭环反馈:工具的执行结果必须回传给 LLM。如果 LLM 不知道工具跑出了什么结果,它就无法基于结果回答问题。
- 状态保持:messages 列表在不断变长,记录了整个思考和执行的过程,这就是短期的“工作记忆”。
类比:
这是一个项目经理的工作流:
- 接到需求(用户提问)。
- 开会讨论(LLM 思考:我该派谁去干?)。
- 指派任务(执行工具)。
- 验收成果(获取 Observation)。
- 汇报/继续(如果成果够了就回复用户;如果不够,带着成果回去继续开会讨论下一步)。
- 只要任务没完成,这个循环就会一直转下去。
第二讲: 从“手搓”到“工业化” —— 引入 LangGraph
如果一直手搓 while 循环和 requests,那叫“造轮子练习”,不叫“工程化开发”。在实际生产中,我们需要处理并发、复杂的状态机、自动重试、流式输出、多 Agent 协作等等,纯手写代码会迅速变成“屎山”。
本讲目标:使用 LangGraph(LangChain 团队推出的专门用于构建 Agent 的状态机框架)重构我们的 Mini-Agent。
为什么选 LangGraph?
- 它比 LangChain 的旧版 AgentExecutor 更可控、更透明。
- 它本质就是一个有环的图(Cyclic Graph),完美对应我们手写的 while 循环。
- 它原生支持状态持久化(为第二讲记忆系统做铺垫)。
- 它是目前构建复杂 Agent 的事实标准。
1. 环境升级:安装新武器
首先,我们需要更新 pyproject.toml,加入 LangChain 和 LangGraph 的依赖。
🛠️ 操作步骤
编辑 pyproject.toml:
在 dependencies 列表中添加以下库:
dependencies = [
"requests>=2.31.0",
"python-dotenv>=1.0.0",
# 新增依赖 👇
"langchain>=0.3.0",
"langchain-community>=0.3.0",
"langgraph>=0.2.0",
"langchain-openai>=0.2.0", # 即使是用本地 Qwen,也常用这个包做兼容适配
]
pip install -e ".[dev]"
2. 核心概念映射:手搓 vs 框架
在写代码前,我们先建立一个思维映射表。你会发现框架并没有魔法,只是把我们刚才做的事标准化了。
| 手搓代码 (v1) | LangGraph 组件 | 作用 |
|---|---|---|
TOOLS_REGISTRY (字典) |
@tool 装饰器 / StructuredTool |
定义工具,自动解析 Docstring 为 Schema |
call_llm (函数) |
ChatModel (如 ChatOpenAI) |
统一的大模型接口,支持流式、绑定工具 |
messages 列表 |
State (TypedDict) |
定义整个 Agent 运行的“全局状态” |
while 循环 + if/else |
StateGraph + add_edge |
定义流程控制:什么时候思考?什么时候行动? |
max_steps 判断 |
RecursionLimit / 条件边 |
防止死循环的控制机制 |
3. 实战重构:用 LangGraph 重写 Agent
创建新文件 src\Mini-Agent\agent_framework.py
3.1 定义工具集
from langchain_core.tools import tool
from datetime import datetime
@tool
def get_current_date() -> str:
"""获取当前的日期和星期几。适用于询问今天周几、日期的问题。"""
now = datetime.now()
# 直接返回包含星期的字符串,杜绝幻觉
return f"日期:{now.strftime('%Y-%m-%d')}, 星期:{now.strftime('%A')}"
@tool
def get_current_time() -> str:
"""获取当前的具体时间(时:分:秒)。适用于询问几点、剩余时间的问题。"""
return f"时间:{datetime.now().strftime('%H:%M:%S')}"
tools = [get_current_date, get_current_time]
3.2 定义状态
这是 LangGraph 的核心。我们需要定义一个“容器”,用来在节点之间传递数据。
from typing import Annotated, Sequence, TypedDict
from langchain_core.messages import BaseMessage
import operator
# 定义状态结构
class AgentState(TypedDict):
# messages 是一个消息列表,operator.add 表示每次更新状态时,新消息会追加到列表后面(而不是覆盖)
messages: Annotated[Sequence[BaseMessage], operator.add]
3.3 构建节点 (Nodes)
节点就是具体的执行逻辑。我们需要两个主要节点:模型节点 和 工具执行节点。
from langchain_openai import ChatOpenAI
from langgraph.prebuilt import ToolNode
# 1. 初始化模型 (指向你的本地 Qwen)
# base_url 指向你的 Docker 服务
llm = ChatOpenAI(
model="qwen-3-4b", # 替换为你之前查到的真实模型名
base_url="http://localhost:7575/v1", # 注意加上 /v1
api_key="not-needed", # 本地通常不需要 key
temperature=0.1
)
# 将工具绑定到模型上,这样模型就知道自己有这些能力了
llm_with_tools = llm.bind_tools(tools)
# 2. 定义“思考”节点
def call_model(state: AgentState):
messages = state["messages"]
response = llm_with_tools.invoke(messages)
return {"messages": [response]}
# 3. 定义“工具执行”节点
# LangGraph 内置了 ToolNode,自动处理工具查找和参数解析,不用我们自己写 registry 了!
tool_node = ToolNode(tools)
3.4 构建图 (Graph) & 路由逻辑
这是替代 while 循环的部分。我们需要定义流程怎么走。
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langchain_core.messages import AIMessage
# 路由函数:决定下一步是去“调工具”还是“直接结束”
def should_continue(state: AgentState):
last_message = state["messages"][-1]
# 如果最后一条消息是 AI 发出的,且包含工具调用请求
if isinstance(last_message, AIMessage) and last_message.tool_calls:
return "tools" # 去工具节点
return END # 否则结束
# 1. 初始化图
workflow = StateGraph(AgentState)
# 2. 添加节点
workflow.add_node("agent", call_model)
workflow.add_node("tools", tool_node)
# 3. 添加边 (Edges)
workflow.add_edge(START, "agent") # 从开始直接进入 agent 思考
workflow.add_conditional_edges(
"agent",
should_continue, # 根据思考结果决定去向
{
"tools": "tools", # 如果有工具调用,去 tools 节点
END: END # 如果没有,结束
}
)
workflow.add_edge("tools", "agent") # 工具执行完后,必须回到 agent 节点进行下一轮思考(形成闭环!)
# 4. 编译成可执行的应用
app = workflow.compile()
3.5 运行 Agent
from langchain_core.messages import HumanMessage
def run_framework_agent(query: str):
print(f"👤 用户:{query}")
inputs = {"messages": [HumanMessage(content=query)]}
# stream 模式可以实时看到每一步的执行(思考 -> 工具 -> 结果)
for event in app.stream(inputs, stream_mode="values"):
last_msg = event["messages"][-1]
role = last_msg.type
content = last_msg.content
# 简单格式化输出
if role == "ai":
if last_msg.tool_calls:
print(f"🧠 思考: 准备调用工具 {[t['name'] for t in last_msg.tool_calls]}")
else:
print(f"🤖 Agent: {content}")
elif role == "tool":
print(f"📝 工具结果: {content}")
if __name__ == "__main__":
query = "现在几点了?顺便告诉我今天是星期几。"
run_framework_agent(query)
报错处理
raise self._make_status_error_from_response(err.response) from None
openai.BadRequestError: Error code: 400 - {'error': {'message': '"auto" tool choice requires --enable-auto-tool-choice and --tool-call-parser to be set', 'type': 'BadRequestError', 'param': None, 'code': 400}}
During task with name 'agent' and id '58f2c8cf-85a9-e17f-2224-7798d7f325ae'
在docker-compose.yaml中添加
--enable-auto-tool-choice--tool-call-parser=qwen
services:
qwen: # 你的服务名
image: vllm/vllm-openai:latest # 假设是 vllm 镜像
ports:
- "7575:8000"
volumes:
- ./models:/models
command:
...
# 👇 新增这两行 👇
--enable-auto-tool-choice
--tool-call-parser=qwen3_xml
...
重启qwen
docker compose down qwen
docker-compose up -d qwen
# 查看日志
docker logs -f qwen-local
👤 用户:现在几点了?顺便告诉我今天是星期几。
👤 用户:现在几点了?顺便告诉我今天是星期几。
🤖 Agent: <think>
好的,用户问现在几点了,还顺便问今天星期几。我需要调用两个工具函数。首先,获取当前时间用get_current_time,然后获取日期和星期几用get_current_date。这样就能同时回答用户的问题了。先调用时间函数,再调用日期函数。确保两个函数都正确调用,然后整合结果回复用户。
</think>
<tool_call>
{"name": "get_current_time", "arguments": {}}
</tool_call>
<tool_call>
{"name": "get_current_date", "arguments": {}}
</tool_call>
这说明 模型已经成功学会了调用工具的格式(它输出了正确的 XML/JSON 标签),但是 LangGraph 的 ToolNode 却找不到这个工具。
又可能是之前设置了qwen3_coder 将其修改为 qwen3_xml
判断模型是否具备OpenAI 风格的工具调用(tool calling)协议
在 http://localhost:7575/docs API文档中调用 /chat/completions这个接口 ,传入如下参数
{
"model": "qwen-3-4b",
"messages": [{"role": "user", "content": "现在几点?"}],
"tools": [{
"type": "function",
"function": {
"name": "get_current_time",
"description": "获取当前时间",
"parameters": {"type": "object", "properties": {}, "required": []}
}
}],
"tool_choice": "auto"
}
输出结构
"tool_calls": [],
"content": "<think>...我需要调用...<tool_call>{\"name\": \"get_current_time\", \"arguments\": {}}<tool_call>"
- tool_calls 是一个 空列表 → LangChain 会认为“没有工具需要调用”
- 工具调用信息被 塞进了 content 字符串里,以自然语言 + 伪 JSON 形式输出
- 这是典型的 “幻觉式工具调用” —— 模型知道自己该调工具,但无法通过结构化方式表达
核心结论: Qwen-3-4B 模型 不支持 OpenAI 风格的工具调用(tool calling)协议。
fix: 后处理模型输出,伪造 tool_calls
从技术上讲,确实可以 “劫持”模型的原始响应,把 <tool>(或 <tool_call>{...}<tool_call>)中提取出的工具调用信息,手动注入到 AIMessage.tool_calls 字段中,从而“欺骗” LangGraph 的 ToolNode,让它以为这是个标准的 OpenAI 工具调用。
我们可以包装 llm_with_tools.invoke(),在它返回 AIMessage 后,解析 content,提取工具调用,并填充 tool_calls 字段。
步骤如下:
- 调用模型(即使不支持 tool calling)
- 从 content 中用正则提取
{"name": "...", "arguments": {...}} - 构造符合 OpenAI 格式的 tool_calls 列表
- 创建一个新的 AIMessage,保留原 content(可选),但设置 tool_calls
- 返回这个“修复后”的消息
import re,json
from langchain_core.messages import AIMessage
def extract_tool_calls_from_content(content: str):
"""从 content 中提取所有被<tool_call>包围的工具调用 JSON"""
tool_calls = []
matches = re.findall(r"<tool_call>\s*({.*?})\s*</tool_call>", content, re.DOTALL)
for i, match in enumerate(matches):
try:
data = json.loads(match.strip())
name = data.get("name")
arguments = data.get("arguments", {})
if name:
# 注意:LangChain 内部使用 'args' 而不是 'arguments'
tool_calls.append({
"name": name,
"args": arguments, # ⚠️ 关键:用 'args'
"id": f"call_{i}_{name}", # 必须有唯一 id
"type": "tool_call"
})
except Exception as e:
print(f"❌ 解析工具调用失败: {e}")
return tool_calls
# 2. 定义“思考”节点
def call_model(state: AgentState):
messages = state["messages"]
raw_response = llm_with_tools.invoke(messages)
# 提取工具调用
extracted_tool_calls = extract_tool_calls_from_content(raw_response.content)
# 创建新的 AIMessage,注入 tool_calls
response = AIMessage(
content=raw_response.content, # 保留原始思考过程(可选)
tool_calls=extracted_tool_calls # 👈 核心:手动注入
)
print("🔧 注入的 tool_calls:", extracted_tool_calls) # 调试用
return {"messages": [response]}
完美解决!
👤 用户:现在几点了?顺便告诉我今天是星期几。
🔧 注入的 tool_calls: [{'name': 'get_current_time', 'args': {}, 'id': 'call_0_get_current_time', 'type': 'tool_call'}, {'name': 'get_current_date', 'args': {}, 'id': 'call_1_get_current_date', 'type': 'tool_call'}]
🧠 思考: 准备调用工具 ['get_current_time', 'get_current_date']
📝 工具结果: 日期:2026-02-27, 星期:Friday
🔧 注入的 tool_calls: []
🤖 Agent: <think>
好的,用户问现在几点了,还顺便问今天星期几。我需要先调用两个工具函数。首先获取当前时间,然后获取日期和星期几。不过用户可能希望得到一个完整的回答,所以需要把时间和服务时间结合起来。但根据工具调用的结果,时间是00:26:15,日期是2026-02-27星期五。需要确认用户是否需要更详细的信息,比如月份或者具体的星期几。不过工具返回
的已经包括星期几了,所以直接整合这两个结果回答用户即可。确保时间格式正确,日期和星期几也正确显示。最后用自然的中文把信息传达给用户。
</think>
现在是00:26:15,今天是2026年2月27日,星期五。
📝 第二讲核心复盘
LangGraph 是 LangChain 团队推出的 基于状态机的可编排 Agent 框架。它把 Agent 拆解为三个核心要素:
✅ State(状态) + Nodes(节点) + Edges(边) = 可编程的工作流
🔑 核心思想:
将 Agent 视为一个有状态的、可循环的、可分支的图(Graph),而非线性脚本。
LangGraph 四大核心特点
| 特点 | 说明 | 优势 |
|---|---|---|
| 1. 显式状态管理 | 通过 TypedDict 定义全局状态(如 messages 列表) |
所有节点共享同一份上下文,避免隐式传递 |
| 2. 节点即函数 | 每个节点是一个纯函数(输入 state → 输出更新) | 逻辑解耦,易于测试和复用 |
| 3. 条件路由 | 支持动态决定下一步走向(如 should_continue) |
实现复杂控制流(循环、分支、终止) |
| 4. 内置工具支持 | ToolNode 自动调度注册的工具 |
无需手动写工具查找、参数解析逻辑 |
💡 LangGraph = 状态驱动 + 函数式 + 声明式流程
代码如何体现 LangGraph 思想?
以上面提供的代码为例,拆解其工业化设计:
1️⃣ State:定义全局记忆
class AgentState(TypedDict):
messages: Annotated[Sequence[BaseMessage], operator.add]
- 所有交互记录(用户、AI、工具)都存于此
operator.add确保新消息追加而非覆盖
2️⃣ Nodes:职责分离
| 节点 | 职责 |
|---|---|
"agent" (call_model) |
负责“思考”:调用 LLM,生成响应(含工具调用) |
"tools" (ToolNode) |
负责“执行”:自动调用对应工具,返回结果 |
即使你的模型不支持原生 tool calling,也能通过后处理注入兼容,不影响节点职责划分。
3️⃣ Edges:声明式流程
START → agent
agent → tools (if tool_calls exist)
agent → END (otherwise)
tools → agent (闭环)
- 形成 “思考 → 执行 → 再思考” 的 ReAct 循环
- 完全声明式,无需
while或if嵌套
LangGraph 工作流程图
┌──────────────┐
│ START │
└──────┬───────┘
▼
┌──────────────┐
│ "agent" │ ←──────────────┐
│ (call_model) │ │
└──────┬───────┘ │
│ │
┌───────────┴───────────┐ │
▼ ▼ │
┌─────────────┐ ┌──────────────┐ │
│ END │ │ "tools" │ │
│ (直接回答) │ │ (ToolNode) │ ────┘
└─────────────┘ └──────────────┘
🔁 执行过程:
- 用户问:“现在几点?今天星期几?”
agent节点调用 LLM,输出包含两个工具调用(被<tool_call>{...}<tool_call>包裹)- 后处理函数提取并注入
tool_calls should_continue检测到tool_calls→ 路由到toolsToolNode并行执行get_current_time和get_current_date- 工具结果作为
ToolMessage追加到messages - 流程回到
agent,LLM 整合结果生成最终回答 - 无新工具调用 → 路由到
END
✅ 整个过程完全自动化、可观测、可扩展
为什么说这是“工业化”?
| 维度 | “手搓”方式 | LangGraph 方式 |
|---|---|---|
| 可维护性 | 逻辑散落在循环中 | 节点职责清晰 |
| 可扩展性 | 加功能需改主循环 | 新增节点+边即可 |
| 可观测性 | 需手动打印日志 | stream() 自动输出每步状态 |
| 健壮性 | 错误处理困难 | 节点隔离,异常可控 |
| 抽象层级 | 面向过程 | 面向工作流(Workflow-Oriented) |
🚀 LangGraph 让你从“写脚本”升级为“设计系统”。
更多推荐


所有评论(0)