← 代码课堂

第 10 课:调用大模型 —— prompt 与链

难度:★★★(需要理解第 08、09 课)
教材:worry_debate_game/ 的 nodes.py(271 行)+ graph.py(99 行)+ main.py
预计时间:讲解 70 分钟 + 练习 50 分钟
学完你能:写出稳定的 prompt、把历史对话喂给模型、让模型输出可编程的 JSON,并看懂 LangGraph 流程编排


课前须知:跟模型说话是一门手艺

调用大模型看起来只是"把字发过去、把字收回来",但要让它稳定、可控、能接进程序,
需要解决四件事:

  1. 配置:API Key 放哪、怎么切换服务商(get_llm)
  2. 提示词(prompt):怎么描述角色和任务,模型才听话
  3. 上下文:模型没有记忆,怎么让它知道前面聊了什么
  4. 可编程输出:怎么让模型吐出"程序能用的数据"而不是大白话(evaluate_tendency)

最后再用 LangGraph 把"生成烦恼 → 天使 → 恶魔 → 下一轮"编排成一张流程图。


第一步:跑起来,先感受"上下文"

  1. 配好 DeepSeek API Key(页面右上角设置),开始一局,玩到第二轮
  2. 重点观察:第二轮的恶魔会针对第一轮和天使本轮的话进行攻击

——它不是每次都从头开始,而是"记得"前面发生了什么

  1. (可选)运行终端版: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                     终端版入口:问主题 → 跑图 → 打印结果

第三步:逐块精讲

块 1: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

四个值得学的设计:

  1. 为什么用 ChatOpenAI 调 DeepSeek?

因为 DeepSeek 提供了兼容 OpenAI 的接口。只要把 base_url 换成 DeepSeek 的地址,
同一套代码就能调两家。这也解释了"为什么一个 OpenAI 的类能连 DeepSeek"。

  1. Key 的来源有优先级:前端设置的(_api_key_override)> 环境变量

(_api_key_override or os.getenv(...) 就是"前面有就用前面,否则用后面")

  1. 绝不把 Key 写死在代码里。Key 要么来自前端输入、要么来自环境变量——

这样代码可以安全地传到 GitHub(复习密钥安全)

  1. 缓存客户端:llm_cache 避免每次发言都重新创建连接对象;

同时 set_api_key 里 clear(),保证换了 Key 之后用的是新客户端。

global 关键字:函数里要修改模块级变量(_api_key_override)时,
必须写 global 声明,否则 Python 会以为你在建一个同名局部变量。

块 2:Prompt 的三段式结构(以 stream_angel 第 74-96 行 为例)

system_prompt = """你是"天使",一个温暖、善良、充满同理心的心理咨询师。...

风格要求:
1. 温暖、共情,理解用户的痛苦
2. 提供建设性的建议和解决方案
3. 积极正向,帮助用户重建信心
4. 语言自然、亲切,像朋友一样
5. 长度控制在100-200字

注意:你需要回应恶魔的攻击,为用户辩护。"""

user_prompt = f"""当前烦恼:{worry}
当前轮次:第{round_num}轮
{history_context}

请作为天使,提供鼓励和支持:"""

SystemMessage(角色设定)和 HumanMessage(具体任务)的分工:

这段 prompt 里有三个可复用的写作技巧:

  1. 给角色(你是天使/心理咨询师)——让语气和立场稳定
  2. 给约束(长度 100-200 字、要回应恶魔)——减少跑偏
  3. 给结构(编号列要求)——模型更容易逐条遵守

反面教材:如果只写"说点鼓励的话",模型可能给你几十字、几百字、
或者反过来教育用户。Prompt 的细致程度,直接决定输出的可用性。

块 3:让模型"记住"历史(第 85-96 行)

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 越多——又慢又贵。
所以真实产品会做"只带最近几轮"或"把旧历史压缩成摘要"。
(这是个很好的面试话题:你怎么控制上下文长度和成本?)

块 4:让模型输出"可编程的数据"(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 应用"最核心的技能之一:让模型返回程序能处理的数据。
三个必学要点:

  1. 明确指定输出格式:prompt 里给出 JSON 模板,并强调"只输出 JSON,不要其他文字"
  2. 永远不要盲信模型输出:它可能加一句"好的,评分如下:"导致解析失败,

所以要 try/except 兜底(默认 5 分)

  1. 数值要"夹逼"(clamp):用 max(0, min(10, x)) 把分数限制在合法范围,

防止模型给出 100 分或 -3 分污染逻辑

max/min 这个组合是夹逼的常用写法:min(10, x) 保证不超过 10,
max(0, ...) 保证不低于 0。

块 5:LangGraph —— 把流程画成图(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_edgesif/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 这类"编排框架"的价值。

块 6:两条路线共享一套逻辑

两条路线都不需要复制 prompt 或调用逻辑——它们共用 nodes.py。
这正是第 06 课讲的"分层"和"复用"带来的好处:换一种交互方式,核心逻辑不用重写。

(读 main.py 时会发现它只做了输入输出,真正的活全在 graph.py 和 nodes.py。好代码就是这样分工的。)


第四步:对照开源,别人怎么用大模型

对照 LangChain 官方写法

项目里的 ChatOpenAI、SystemMessage、HumanMessage、llm.invoke/stream
就是 LangChain 的标准用法,和官方文档一致。可以去官方文档看看
"Prompt Templates"(提示词模板)——那是把手写的 f-string 进一步模板化/复用。

Prompt 工程可复用的技巧

技巧说明教材项目里
角色扮演先定义"你是谁""你是天使/恶魔/裁判"
约束输出限定长度、语气、边界"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),用终端版跑一局看效果。
做完你就掌握了"往流程里加节点"的基本操作。


本课小结

术语表

术语人话解释
LLM大语言模型
prompt提示词,你发给模型的指令和资料
system / user message角色设定 / 本次任务,两种消息
无状态每次调用互不相干,不记得上次
上下文(context)你这次一起发过去的历史信息
token模型处理文字的单位,也是计费单位
结构化输出让模型返回 JSON 等程序可解析的数据
夹逼 / clamp把数值限制在合法区间内
LangChain调用模型的工具库
LangGraph把多个模型步骤编排成流程图的库
checkpointer / MemorySaver流程状态的存档,支持暂停与恢复
中断(interrupt)流程跑到某一步停下,等外部输入再继续

下节预告

第 11 课:组件化:React/Next.js 的模块思想。
教材换成 shijing-v5-dev/src/——看现代前端框架是怎么把页面拆成
一个个"组件"的,以及它的"数据变了界面自动变"是怎么做到的。
上完这课,你就能看懂市面上大多数前端项目了。