这节课不增加业务功能,只做一件事:把模型、工具、流程和终端交互分开。
一、为什么要拆分?
现在第九课脚本把所有内容放在一起,而且直接执行了:
model = ChatOpenAI(...)
graph = builder.compile(...)
while True:
question = input(...)
如果其他文件导入它,也会创建模型、进入输入循环。
我们希望变成:
from bookstore_app.graph import build_graph
# 导入时不开始聊天,需要时才创建图
graph = build_graph()
Python 导入模块会执行其顶层语句;把启动操作放进函数,就能控制执行时机。Python 官方说明
二、这一课的目录
在 C:\Users\Administrator\langchain-study 下新建 bookstore_app 文件夹:
langchain-study/
├── bookstore_app/
│ ├── __init__.py
│ ├── config.py 配置
│ ├── model.py 创建模型
│ ├── tools.py 数据、参数类型和业务工具
│ ├── graph.py 提示词和图的组装
│ └── cli.py 终端输入、输出和人工确认
│
├── unit5_lesson9_idempotency.py 继续复用数据库模块
├── simulated_orders.sqlite 原有数据库,位置不变
└── 其他历史课程文件 全部保留
这一课先复用现成的数据库模块,不搬数据库、不复制保存函数、不覆盖旧课程代码。
__init__.py 留空即可,用于把这个目录组织成普通 Python 包。
三、配置文件:config.py
新建 C:\Users\Administrator\langchain-study\bookstore_app\config.py:
MODEL_NAME = "deepseek-flash"
BASE_URL = "https://api.deepseek.com"
# 保持你现有代码的设置
USER_ID = "B"
RECURSION_LIMIT = 10
配置文件只放配置,不创建模型、不连接数据库。
密钥仍从环境变量读取,不写在这里。
四、模型文件:model.py
新建 C:\Users\Administrator\langchain-study\bookstore_app\model.py:
import os
from langchain_openai import ChatOpenAI
from bookstore_app.config import BASE_URL, MODEL_NAME
def create_model() -> ChatOpenAI:
return ChatOpenAI(
model=MODEL_NAME,
base_url=BASE_URL,
api_key=os.environ["DEEPSEEK_API_KEY"],
extra_body={"thinking": {"type": "disabled"}},
)
这里保持你原来的模型设置不变。
注意两种写法的区别:
# 导入模块时,就创建模型对象
model = ChatOpenAI(...)
# 导入模块时,只定义函数
def create_model():
return ChatOpenAI(...)
第二种需要执行 create_model() 才创建模型对象。创建对象与发送模型请求也不同;真正请求模型是在后面的 .invoke()。
五、工具文件:tools.py
新建 C:\Users\Administrator\langchain-study\bookstore_app\tools.py,先放这些导入:
from dataclasses import dataclass
from decimal import Decimal
from langchain.tools import tool, ToolRuntime
from langchain_core.tools import ToolException
from langgraph.types import interrupt
from pydantic import BaseModel, Field
from unit5_lesson9_idempotency import save_simulated_order
接着从你的第九课助手(C:/Users/Administrator/langchain-study/unit5_lesson9_order_agent.py)中,原样复制以下定义,按这个顺序放入:
BOOK_STOCKBOOK_PRICESUSERSUserContext,包含它上面的@dataclass(frozen=True)QuoteInputget_book_stock,包含@toolget_book_price,包含@toolcalculate_quote,包含它的装饰器simulate_order,包含它的装饰器
最后添加:
TOOLS = [
get_book_stock,
get_book_price,
calculate_quote,
simulate_order,
]
不要复制 原文件里的模型创建、model.bind_tools(...)、图构建、输入循环,以及已经注释掉的临时测试。
这一文件只负责:
有哪些工具,以及每个工具具体做什么。
六、流程文件:graph.py
新建 C:\Users\Administrator\langchain-study\bookstore_app\graph.py:
from langchain_core.messages import SystemMessage
from langchain_core.tools import ToolException
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import StateGraph, MessagesState, START, END
from langgraph.prebuilt import ToolNode, tools_condition
from pydantic import ValidationError
from bookstore_app.model import create_model
from bookstore_app.tools import TOOLS, UserContext
# 请把第九课 SystemMessage(content=(...)) 内的
# 全部提示词字符串复制到这对括号中,保持原文不变。
SYSTEM_PROMPT = (
# 此处放原有的全部提示词
)
def build_graph():
model = create_model()
model_with_tools = model.bind_tools(TOOLS)
def call_model(state: MessagesState) -> dict:
print("[模型节点] 正在判断或回答......")
response = model_with_tools.invoke([
SystemMessage(content=SYSTEM_PROMPT),
*state["messages"],
])
for call in response.tool_calls:
print(
"[工具请求]",
call["name"],
call["args"],
)
return {"messages": [response]}
builder = StateGraph(
MessagesState,
context_schema=UserContext,
)
builder.add_node("assistant", call_model)
builder.add_node(
"tools",
ToolNode(
TOOLS,
handle_tool_errors=(
ToolException,
ValidationError,
),
),
)
builder.add_edge(START, "assistant")
builder.add_conditional_edges(
"assistant",
tools_condition,
{
"tools": "tools",
"__end__": END,
},
)
builder.add_edge("tools", "assistant")
return builder.compile(
checkpointer=InMemorySaver(),
)
这里有一个必须手动填入的地方:SYSTEM_PROMPT。
例如,你原文件中的:
SystemMessage(content=(
"你是书店助手,请简短回答。"
"涉及库存时,调用 get_book_stock;"
# 后面还有其他规则
))
把括号里的全部字符串 搬到 SYSTEM_PROMPT,不是只复制上面两句。相邻字符串会自动拼接成一个字符串。
两个重点:
call_model放在build_graph内部,可以使用外层创建的model_with_tools。- 每次
build_graph()都创建新的图和内存检查点。因此应该在聊天循环外调用一次,不能每轮重新创建。
七、入口文件:cli.py
新建 C:\Users\Administrator\langchain-study\bookstore_app\cli.py:
from langchain_core.messages import HumanMessage, ToolMessage
from langgraph.errors import GraphRecursionError
from langgraph.types import Command
from bookstore_app.config import USER_ID, RECURSION_LIMIT
from bookstore_app.graph import build_graph
from bookstore_app.tools import UserContext
def main():
# 整个聊天过程中只创建一次
graph = build_graph()
context = UserContext(user_id=USER_ID)
config = {
"configurable": {
"thread_id": f"bookstore:{USER_ID}:chat-1",
},
"recursion_limit": RECURSION_LIMIT,
}
print("书店助手已启动,输入 exit 结束。")
print("当前演示用户:", USER_ID)
turn = 1
while True:
question = input("\n你:").strip()
if question.lower() == "exit":
print("聊天结束。")
break
if not question:
continue
print(f"\n第 {turn} 轮问题:{question}")
snapshot = graph.get_state(config)
previous_count = len(
snapshot.values.get("messages", [])
)
try:
result = graph.invoke(
{"messages": [HumanMessage(content=question)]},
config=config,
context=context,
)
while result.get("__interrupt__"):
decisions = {}
for pending in result["__interrupt__"]:
details = pending.value
print("\n===== 请人工确认:仅为模拟 =====")
print("用户:", details["user_id"])
print("图书:", details["book_name"])
print("数量:", details["quantity"])
print(
"模拟金额:",
details["total_yuan"],
"元",
)
choice = input(
"输入 yes 同意,其他输入均拒绝:"
).strip().lower()
decisions[pending.id] = choice == "yes"
result = graph.invoke(
Command(resume=decisions),
config=config,
context=context,
)
except GraphRecursionError:
print("流程达到步数上限,已停止,请排查后重新启动。")
break
new_messages = result["messages"][previous_count:]
for message in new_messages:
if isinstance(message, ToolMessage):
print("工具状态:", message.status)
print("工具结果:", message.content)
print("助手:", result["messages"][-1].content)
print("累计消息数:", len(result["messages"]))
turn += 1
if __name__ == "__main__":
main()
最后两行的意思是:
作为启动入口运行时,执行
main();被其他模块导入时,不自动开始聊天。
八、怎么运行和检查?
在 CMD 中:
cd /d C:\Users\Administrator\langchain-study
.venv\Scripts\activate
先检查导入:
python -c "import bookstore_app.cli; print('导入成功,没有启动聊天')"
预期只输出:
导入成功,没有启动聊天
如果它开始询问"你:",说明还有输入循环放在函数外面。
接着启动:
python -m bookstore_app.cli
这里的 -m 表示按模块名运行。使用这条命令,不要直接运行包内的 cli.py 文件。
依次测试:
| 输入 | 检查点 |
|---|---|
| 三体还有库存吗? | 库存为 8 本 |
| 买两本多少钱? | 能沿用书名;用户 B 报价为 90 元 |
| 帮我模拟下单两本三体 | 出现人工确认 |
| 在确认处输入 no | 不保存本次模拟订单 |
| 再明确发起一次模拟下单,确认处输入 yes | 保存本地模拟记录,不扣款、不扣库存 |
也可以重新运行旧测试:
python -m unittest -v test_unit5_lesson10_idempotency test_unit5_lesson11_approval
注意:这七项旧测试仍检查原有保存函数和简化审批图,不能单独证明这次拆分后的完整助手没有问题,上面的导入检查和交互检查也要做。
本课记住一句话:
工具负责业务,图负责流程,入口负责交互;导入模块不应该自动开始聊天。
你先做到"导入检查通过、书店助手能启动"。下一课我们就在这个结构中增加知识库检索工具。