第五单元——第十二课:把脚本整理成项目

这节课不增加业务功能,只做一件事:把模型、工具、流程和终端交互分开。

一、为什么要拆分?

现在第九课脚本把所有内容放在一起,而且直接执行了:

复制代码
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)中,原样复制以下定义,按这个顺序放入:

  1. BOOK_STOCK
  2. BOOK_PRICES
  3. USERS
  4. UserContext,包含它上面的 @dataclass(frozen=True)
  5. QuoteInput
  6. get_book_stock,包含 @tool
  7. get_book_price,包含 @tool
  8. calculate_quote,包含它的装饰器
  9. 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

注意:这七项旧测试仍检查原有保存函数和简化审批图,不能单独证明这次拆分后的完整助手没有问题,上面的导入检查和交互检查也要做。


本课记住一句话:

工具负责业务,图负责流程,入口负责交互;导入模块不应该自动开始聊天。

你先做到"导入检查通过、书店助手能启动"。下一课我们就在这个结构中增加知识库检索工具。

相关推荐
全栈练习生1 小时前
Jev 决策模型
python·ai·1024程序员节
光依旧1 小时前
day-trader-skill实测:让Claude Code当交易助理,纯Python+确定性+fail-closed
python·量化交易·ai agent·a股·风控·claude code·开源项目实测
yuniko-n1 小时前
【JUC】四种内存屏障示例
java·1024程序员节
cuijiecheng20181 小时前
WeFlow 无法使用怎么办?一次数据库组件到期问题的排查与修复
数据库·oracle
界首大学1 小时前
CUDA C++ 高效入门第四章 -- CUDA 同步技术之线程块同步
1024程序员节·cuda c++
宇明一不急1 小时前
numa拓扑感知调度
1024程序员节
Stitch .1 小时前
投稿正文_图床版
1024程序员节
stereohomology1 小时前
更新字帖发现zcode的功能很丰富:dynamic workflows的表现
1024程序员节
张小姐的猫1 小时前
【MySQL】内置函数
1024程序员节