难度:★★★(需要理解第 08、09 课)
教材:worry_debate_game/的nodes.py(271 行)+graph.py(99 行)+main.py
预计时间:讲解 70 分钟 + 练习 50 分钟
学完你能:写出稳定的 prompt、把历史对话喂给模型、让模型输出可编程的 JSON,并看懂 LangGraph 流程编排
调用大模型看起来只是"把字发过去、把字收回来",但要让它稳定、可控、能接进程序,
需要解决四件事:
get_llm)evaluate_tendency)最后再用 LangGraph 把"生成烦恼 → 天使 → 恶魔 → 下一轮"编排成一张流程图。
——它不是每次都从头开始,而是"记得"前面发生了什么
py -3 -m worry_debate_game.main,体验 graph.py 编排的完整流程
nodes.py
├── get_llm() 第 17-39 行 拿一个 LLM 客户端(带缓存、支持 DeepSeek/OpenAI)
├── set_api_key() 第 11-14 行 前端设置 Key 时调用,并清掉旧客户端
├── generate_worry_node() 第 42-63 行 把主题变成第一人称烦恼
├── stream_angel() 第 66-105 行 天使发言(流式,第 09 课讲过)
├── stream_demon() 第 108-150 行 恶魔发言(流式)
├── angel_node() 第 153-188 行 天使发言(一次性返回版)
├── demon_node() 第 191-229 行 恶魔发言(一次性返回版)
└── evaluate_tendency() 第 232-271 行 裁判打分,输出 JSON
graph.py
├── should_continue() 第 7-10 行 条件分支:该继续还是结束
├── create_graph() 第 13-47 行 把节点连成流程图
└── run_game_interactive() 第 54-99 行 终端版交互循环
main.py 终端版入口:问主题 → 跑图 → 打印结果
get_llm() —— 客户端与密钥管理(第 17-39 行)llm_cache = {}
_api_key_override: Optional[str] = None
def set_api_key(key: str):
global _api_key_override
_api_key_override = key
llm_cache.clear() # Key 变了,旧客户端作废
def get_llm(model: str = "deepseek-chat", provider: str = "deepseek"):
cache_key = f"{provider}_{model}"
if cache_key in llm_cache:
return llm_cache[cache_key]
if provider == "deepseek":
api_key = _api_key_override or os.getenv("DEEPSEEK_API_KEY")
if not api_key:
raise ValueError("请在设置中填写 DeepSeek API Key")
llm = ChatOpenAI(model=model, api_key=api_key, base_url="https://api.deepseek.com")
else:
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
raise ValueError("请设置 OPENAI_API_KEY 环境变量")
llm = ChatOpenAI(model=model, api_key=api_key)
llm_cache[cache_key] = llm
return llm
四个值得学的设计:
ChatOpenAI 调 DeepSeek?因为 DeepSeek 提供了兼容 OpenAI 的接口。只要把 base_url 换成 DeepSeek 的地址,
同一套代码就能调两家。这也解释了"为什么一个 OpenAI 的类能连 DeepSeek"。
_api_key_override)> 环境变量(_api_key_override or os.getenv(...) 就是"前面有就用前面,否则用后面")
这样代码可以安全地传到 GitHub(复习密钥安全)
llm_cache 避免每次发言都重新创建连接对象;同时 set_api_key 里 clear(),保证换了 Key 之后用的是新客户端。
global 关键字:函数里要修改模块级变量(_api_key_override)时,
必须写 global 声明,否则 Python 会以为你在建一个同名局部变量。
stream_angel 第 74-96 行 为例)system_prompt = """你是"天使",一个温暖、善良、充满同理心的心理咨询师。...
风格要求:
1. 温暖、共情,理解用户的痛苦
2. 提供建设性的建议和解决方案
3. 积极正向,帮助用户重建信心
4. 语言自然、亲切,像朋友一样
5. 长度控制在100-200字
注意:你需要回应恶魔的攻击,为用户辩护。"""
user_prompt = f"""当前烦恼:{worry}
当前轮次:第{round_num}轮
{history_context}
请作为天使,提供鼓励和支持:"""
SystemMessage(角色设定)和 HumanMessage(具体任务)的分工:
system_prompt:告诉模型"你是谁、什么风格、有什么规矩"——长期设定user_prompt:这一次的具体任务和数据——临时输入这段 prompt 里有三个可复用的写作技巧:
反面教材:如果只写"说点鼓励的话",模型可能给你几十字、几百字、
或者反过来教育用户。Prompt 的细致程度,直接决定输出的可用性。
history_context = ""
if angel_history or demon_history:
history_context = "\n\n历史对话:\n"
for i, (angel_msg, demon_msg) in enumerate(zip(angel_history, demon_history)):
history_context += f"第{i+1}轮天使:{angel_msg}\n"
history_context += f"第{i+1}轮恶魔:{demon_msg}\n"
关键认知:大模型 API 本身是无状态的——每次调用都是"失忆"的,
它不记得你上一次发过什么。所谓"记忆",是你每次把历史重新塞回去。
这里的做法:把前几轮的天使/恶魔发言拼成文本,放进这次的用户消息里。zip(a, b) 把两个列表按位置配对(第 1 轮天使配第 1 轮恶魔),enumerate 提供序号,拼成"第 N 轮……"的格式。
代价:历史越长,每次请求的输入 token 越多——又慢又贵。
所以真实产品会做"只带最近几轮"或"把旧历史压缩成摘要"。
(这是个很好的面试话题:你怎么控制上下文长度和成本?)
evaluate_tendency 第 232-271 行)system_prompt = """你是一个辩论裁判...
输出格式(只输出 JSON 纯净文本,不要有其他文字):
{"angel": 5, "demon": 5}"""
...
try:
response = llm.invoke([...])
scores = json.loads(response.content.strip()) # 把文本当 JSON 解析
angel_score = max(0, min(10, scores.get("angel", 5))) # 夹逼到 0-10
demon_score = max(0, min(10, scores.get("demon", 5)))
except Exception:
angel_score = 5 # 解析失败就用默认分,程序不崩
demon_score = 5
shift = angel_score - demon_score
current = state.get("tendency", 0)
new_tendency = max(-100, min(100, current + shift)) # 总分限制在 -100~100
这是"AI 应用"最核心的技能之一:让模型返回程序能处理的数据。
三个必学要点:
所以要 try/except 兜底(默认 5 分)
max(0, min(10, x)) 把分数限制在合法范围,防止模型给出 100 分或 -3 分污染逻辑
max/min 这个组合是夹逼的常用写法:min(10, x) 保证不超过 10,max(0, ...) 保证不低于 0。
graph.py)workflow = StateGraph(GameState)
workflow.add_node("generate_worry", generate_worry_node)
workflow.add_node("angel", angel_node)
workflow.add_node("demon", demon_node)
workflow.add_node("increment_round", increment_round_node)
workflow.set_entry_point("generate_worry") # 从哪开始
workflow.add_edge("generate_worry", "angel") # 固定顺序
workflow.add_edge("angel", "demon")
workflow.add_conditional_edges( # 条件分支
"demon", should_continue,
{"increment_round": "increment_round", END: END}
)
workflow.add_conditional_edges(
"increment_round",
lambda state: "angel" if state["game_active"] else END,
{"angel": "angel", END: END}
)
checkpointer = MemorySaver()
return workflow.compile(checkpointer=checkpointer, interrupt_after=["demon"])
概念对照(用你已经会的东西理解它):
| LangGraph 概念 | 你已经见过的东西 |
|---|---|
add_node(名字, 函数) | 第 03 课 MVC 的"每个方法干一件事" |
add_edge(A, B) | 普通流程:A 完了走 B |
add_conditional_edges | if/else 分支 |
StateGraph(GameState) | 节点之间共享的那个"状态字典" |
MemorySaver(checkpointer) | 存档功能:记住每次运行到哪、状态是什么 |
interrupt_after=["demon"] | 每轮恶魔说完就暂停,等用户决定是否继续 |
should_continue(第 7-10 行)就是分支判断:
轮次到上限返回 END(结束),否则回 increment_round(加一轮再来)。
run_game_interactive(第 54-99 行)是终端版的交互循环:
用 graph.invoke 跑到中断点 → 打印本轮内容 → input() 问用户要不要继续
→ graph.stream(None, config) 从中断处继续跑。
为什么要用图,不直接写 while 循环?
图把"流程"变成了看得见、改得动的结构:想加一个"裁判节点",
只要 add_node + add_edge;想加分支,就加条件边。
而手写 while + if 的流程一复杂就难看清。这也是 LangGraph 这类"编排框架"的价值。
app.py):按前端请求,直接调用 nodes.py 里的函数(流式)graph.py + main.py):用 LangGraph 编排整局流程两条路线都不需要复制 prompt 或调用逻辑——它们共用 nodes.py。
这正是第 06 课讲的"分层"和"复用"带来的好处:换一种交互方式,核心逻辑不用重写。
(读 main.py 时会发现它只做了输入输出,真正的活全在 graph.py 和 nodes.py。好代码就是这样分工的。)
项目里的 ChatOpenAI、SystemMessage、HumanMessage、llm.invoke/stream
就是 LangChain 的标准用法,和官方文档一致。可以去官方文档看看
"Prompt Templates"(提示词模板)——那是把手写的 f-string 进一步模板化/复用。
| 技巧 | 说明 | 教材项目里 |
|---|---|---|
| 角色扮演 | 先定义"你是谁" | "你是天使/恶魔/裁判" |
| 约束输出 | 限定长度、语气、边界 | "100-200字"、"回应恶魔" |
| 结构化输出 | 要求 JSON 并给模板 | evaluate_tendency |
| 分步要求 | 用编号列清任务 | 各 prompt 的"风格要求 1-5" |
| 带上下文 | 把历史/资料放进当前消息 | history_context |
| 方案 | 特点 |
|---|---|
| 手写 while/if(最容易理解) | 流程简单时够用,复杂后难维护 |
| LangGraph(本项目) | 把流程画成图,支持中断/存档/分支 |
| 其他(CrewAI、AutoGen 等) | 面向"多智能体协作"的不同抽象 |
没有最好,只有合不合适。 项目的需求是"多角色、多轮、可暂停",LangGraph 很贴。
真实项目还要关心:temperature(随机性)、max_tokens(长度上限)、
重试与限流。教材的代码已经做了两件相关的事:客户端缓存和失败兜底。
练习 1(热身):改 prompt,看变化
把 stream_angel 的 system prompt 里"长度控制在100-200字"改成"50 字以内",
重新跑一局,观察天使发言明显变短。体会:prompt 里每一个约束都在起作用。
练习 2(必做):加一个"赛后总结"函数
在 nodes.py 里加:
def summarize_debate(state: GameState) -> str:
llm = get_llm("deepseek-chat")
history = "\n".join(f"第{i+1}轮 天使:{a}\n恶魔:{d}"
for i, (a, d) in enumerate(zip(state["angel_history"], state["demon_history"])))
prompt = f"请用 100 字总结这场辩论,并给用户一句建议:\n烦恼:{state['worry']}\n{history}"
return llm.invoke(prompt).content
然后在终端里用一小段脚本调用它(构造一个 state 即可),打印结果。
可以先用非流式的 angel_node 造两轮历史,再调用 summarize_debate。
练习 3(必做):让模型输出更"可编程"evaluate_tendency 现在只返回 {angel, demon}。
试着让它返回 {"angel": 5, "demon": 5, "reason": "一句话理由"},
并在 prompt 里说明格式。改完运行一次,把理由打印出来。
重点体会:改 prompt 就能改变程序能拿到的数据字段。
练习 4(容错实验):
把 evaluate_tendency 里 prompt 的"只输出 JSON"删掉,再运行几次,
观察 json.loads 是不是更频繁地失败、容错分支(默认 5 分)是不是会被触发。
(这个实验让你看到"模型不听话"是常态,容错是必须的。)
练习 5(思考题):
为什么 set_api_key 里要清空 llm_cache?如果不清会发生什么?
客户端对象里已经绑定了 Key。
练习 6(选做,进阶):
在 graph.py 里加一个 summary 节点(调用练习 2 的函数),
把它接在流程的末尾(demon → summary),用终端版跑一局看效果。
做完你就掌握了"往流程里加节点"的基本操作。
ChatOpenAI 调,是因为它兼容 OpenAI 接口,改 base_url 即可nodes.py 同时服务网页版和终端版——复用的价值| 术语 | 人话解释 |
|---|---|
| LLM | 大语言模型 |
| prompt | 提示词,你发给模型的指令和资料 |
| system / user message | 角色设定 / 本次任务,两种消息 |
| 无状态 | 每次调用互不相干,不记得上次 |
| 上下文(context) | 你这次一起发过去的历史信息 |
| token | 模型处理文字的单位,也是计费单位 |
| 结构化输出 | 让模型返回 JSON 等程序可解析的数据 |
| 夹逼 / clamp | 把数值限制在合法区间内 |
| LangChain | 调用模型的工具库 |
| LangGraph | 把多个模型步骤编排成流程图的库 |
| checkpointer / MemorySaver | 流程状态的存档,支持暂停与恢复 |
| 中断(interrupt) | 流程跑到某一步停下,等外部输入再继续 |
第 11 课:组件化:React/Next.js 的模块思想。
教材换成 shijing-v5-dev/src/——看现代前端框架是怎么把页面拆成
一个个"组件"的,以及它的"数据变了界面自动变"是怎么做到的。
上完这课,你就能看懂市面上大多数前端项目了。