我带学员做 AI 项目,第一天挂掉的人里,十个有七个不是死在代码上,是死在环境上。
典型现场是这样的:教程说 pip install xxx,他装了,运行报错说找不到包;再装一次,装到全局去了,把另一个项目的版本顶掉了;上网搜,看到有人说用 root 装,加 sudo,装完能跑了,第二天开机又不行。来回折腾三个晚上,兴趣磨没了,人也没影了。
这一篇我不打算教你"复制这几行命令"。我要把为什么要这么做讲清楚——虚拟环境到底隔离了什么、API Key 为什么不能写进代码、.env 是怎么被读进去的、一次问答在网络上到底发生了什么。你把这些弄明白,后面九篇的代码才不会变成"照抄能跑,改一行就崩"。
这一篇结束的时候,你会有一个真的能跑的工程:配了 Key 就调真实 DeepSeek,没配 Key 也能把整条链路跑通并让测试全绿。
一、先扫盲:五个概念,不懂它们后面全是黑盒
1. 虚拟环境:给每个项目一间独立的房间
你机器上可能同时跑着好几个 Python 项目。A 项目要 requests 2.28,B 项目要 requests 2.32,装在系统里只能留一个版本,于是必有一个项目崩。
虚拟环境(virtualenv)做的事很朴素:把一份 Python 解释器和一个空的 site-packages 目录复制到项目里的 .venv/ 文件夹下。激活它,你用的 python 和 pip 就全指向这个文件夹,装的包只躺在里面。它不是模拟器,不是容器,就是一个被隔离出来的目录。
怎么判断自己有没有激活成功?看命令行提示符前面有没有 (.venv),或者跑一句:
which python
# 输出应该是:/你的项目路径/.venv/bin/python
# 如果输出是 /usr/bin/python 或 /opt/homebrew/bin/python,说明还在用系统的
2. pip:把别人写好的代码搬到你机器上
Python 能火,一半功劳在生态。你要发 HTTP 请求,不用自己写 socket,直接 pip install httpx 就能用别人写好的。
requirements.txt 是这份"购物清单"的固化:把你项目用到的包和版本写下来,换台机器 pip install -r requirements.txt 就能还原。我这次踩到一个坑,正好说出来:国内直连 PyPI 常常很慢,换清华镜像我却吃到了 403——镜像源之间同步有延迟,某些冷门包没拉全。最后换成阿里云镜像一次成功。这不是玄学,换源重试是标准操作。
pip install -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple/
3. API Key:一把会被计费的钥匙
调大模型不是免费的,平台靠 Key 认你是谁、扣谁的钱。所以:
- 绝不能写死在代码里。代码一旦推到公开仓库,爬虫几分钟就能扫到你的 Key,然后你的余额就没了。
- 放 .env 文件,并把 .env 写进 .gitignore。代码从环境变量读(.env 只是本地开发的便捷方式)。
为什么 Key 会丢?不是黑客多高明,是忘了加 .gitignore。这是我见过最普遍的一次性损失。
4. HTTP 请求与 JSON:模型交互的全部秘密
调大模型本质上就是一次 HTTPS 请求:你 POST 一段 JSON 过去,服务器回一段 JSON 回来。没了。
请求体大概长这样(DeepSeek 是 OpenAI 兼容格式):
{
"model": "deepseek-flash",
"messages": [
{"role": "system", "content": "你是英语助教"},
{"role": "user", "content": "这个句子对吗?"}
],
"temperature": 0.7
}
role 有三种:system 定身份、user 是用户说的、assistant 是模型回的。注意最后一点:服务端不保存任何东西。 你以为的"多轮对话",其实是每一次都把前面所有内容重发一遍。这一点到下一篇讲上下文时会要命——它是 Token 账单失控的根源。
5. 模型名换代:抄旧教程会直接 404
这条必须单独拎出来讲,因为我查官方文档时确认了一件很多人还不知道的事:
deepseek-chat 和 deepseek-reasoner 这两个名字,已经在 2026 年 7 月 24 日退役了。
现在正确的是:
你要是照着 2025 年的博客抄 deepseek-chat,拿到的是冷冰冰的 404。所以我养成一个习惯:每次开新项目,先去官方文档瞄一眼模型名,比事后 debug 一小时便宜太多。
另一个小坑:DeepSeek 的 base_url 是 https://api.deepseek.com,后面没有 /v1。很多人习惯性加上路由前缀,然后得到一串看不懂的报错。
二、工程长什么样
我按"一个功能一个文件"的方式来组织,为的是你改某一块时不用在几百行里翻:
01-环境与首次调用/
├── .env.example # 配置样例;复制成 .env 后填自己的 Key
├── .gitignore # 必须包含 .env 和 .venv/
├── requirements.txt # 依赖清单
├── pytest.ini # 告诉 pytest 去哪儿找测试
├── demo.py # 一键演示:有 Key 走真实,没 Key 走离线
├── src/
│ ├── settings.py # 配置集中读取
│ ├── llm.py # 模型工厂(真/假两条路)
│ ├── chat.py # 消息拼装与问答
│ └── graph.py # 最小 LangGraph 图
└── tests/ # 五个测试文件,全程离线可跑
依赖就四个:langchain-deepseek、langgraph、python-dotenv、pytest。我这次装到的版本是 langchain-deepseek 1.1.1、langgraph 1.2.18、langchain-core 1.7.0,Python 是 3.13.12。
三、第一步:配置集中到一处(settings.py)
新手最爱在代码各处写 os.getenv("DEEPSEEK_API_KEY")。等你要换模型、换地址、给同事部署,就得满项目搜索。集中起来,改一处搞定。
"""配置集中管理:只在这一处读环境变量。"""
from __future__ import annotations
import os
from dataclasses import dataclass
from dotenv import load_dotenv
# DeepSeek 的 OpenAI 兼容入口,官方文档给出的地址
# 注意:不是 https://api.deepseek.com/v1,DeepSeek 直接用根路径
DEEPSEEK_BASE_URL = "https://api.deepseek.com"
# 2026 年当前的主力模型(旧的 deepseek-chat / deepseek-reasoner 已退役)
DEFAULT_MODEL = "deepseek-flash"
@dataclass(frozen=True)
class Settings:
"""一个只读的配置容器。
frozen=True 表示创建后不能再改,
避免代码某个角落偷偷把 model 改掉,排查半天。
"""
api_key: str = ""
base_url: str = DEEPSEEK_BASE_URL
model: str = DEFAULT_MODEL
@property
def has_key(self) -> bool:
"""有没有配 Key。"""
return bool(self.api_key)
def load_settings(env_file: str | None = None, override: bool = False) -> Settings:
"""读取配置。
env_file : 指定 .env 文件路径,传 None 就用项目根目录的默认 .env
override : True 表示 .env 里的值覆盖已有系统环境变量。
"""
# load_dotenv 会把 .env 里的键值对写进 os.environ
# 这一行执行前,os.getenv 是读不到 .env 内容的
load_dotenv(env_file, override=override)
return Settings(
api_key=os.getenv("DEEPSEEK_API_KEY", "").strip(),
base_url=os.getenv("DEEPSEEK_BASE_URL", DEEPSEEK_BASE_URL).strip(),
model=os.getenv("DEEPSEEK_MODEL", DEFAULT_MODEL).strip(),
)
两个细节值得说:
- @dataclass(frozen=True):frozen 让实例只读。这不是炫技——配置对象在代码里到处传,一旦被某个函数悄悄改了 model,你能查到怀疑人生。
- .strip():.env 文件是在行尾多打了一个空格导致的"Key 无效",这类问题我见过太多次,strip 一下省事。
四、第二步:模型工厂,留一条不花钱的退路(llm.py)
这是本篇里我认为最重要的一段设计。它解决一个现实问题:写教程的人不能假设读者手上有 Key。
from langchain_core.language_models import BaseChatModel
from langchain_core.language_models.fake_chat_models import FakeListChatModel
from langchain_deepseek import ChatDeepSeek
from settings import Settings
DEFAULT_TEMPERATURE = 0.7
DEFAULT_TIMEOUT = 60
def build_llm(
settings: Settings,
*,
temperature: float = DEFAULT_TEMPERATURE,
timeout: int = DEFAULT_TIMEOUT,
offline_responses: Sequence[str] | None = None,
) -> BaseChatModel:
"""创建一个聊天模型。
返回值类型是 BaseChatModel(抽象基类)而不是具体类,
这叫"面向接口编程":调用方只管 .invoke() 能用,
不在意背后是真模型还是假模型。
"""
# 通路一:离线假模型,给测试和演示用
if offline_responses is not None:
# FakeListChatModel 会按列表顺序依次返回,用完从头再来
return FakeListChatModel(responses=list(offline_responses))
# 通路二:真实 DeepSeek
if not settings.has_key:
raise RuntimeError(
"没有读到 DEEPSEEK_API_KEY。\n"
"两种解决方式:\n"
" 1. 在项目根目录建 .env 文件,写一行 DEEPSEEK_API_KEY=sk-xxx\n"
" 2. 或者调用 build_llm(..., offline_responses=['...']) 走离线模式"
)
return ChatDeepSeek(
model=settings.model,
api_key=settings.api_key,
api_base=settings.base_url,
temperature=temperature,
timeout=timeout,
max_retries=2, # 网络抖动时自动重试两次
)
三条通路,按优先级判断:
- 传了 offline_responses → 假模型,不发网络请求、不花钱
- 有 Key → 真模型 ChatDeepSeek
- 没 Key 又没给离线答案 → 抛出一句告诉人怎么修的错误
第三条是我特意做的。默认情况下缺 Key 会抛一大串看不懂的认证异常,新手直接懵。换成"你该干什么",体验完全不同:
has_key = False
RuntimeError:
没有读到 DEEPSEEK_API_KEY。
两种解决方式:
1. 在项目根目录建 .env 文件,写一行 DEEPSEEK_API_KEY=sk-xxx
2. 或者调用 build_llm(..., offline_responses=['...']) 走离线模式
temperature=0.7 是什么?它控制回答的随机程度。0 接近确定(同一句提问几乎同一句回答),1 以上天马行空。做英语教学这种讲语法的场景,我建议压到 0.3 以下——你不想让助教把时态讲得一回一个样。
五、第三步:拼消息,看清"记忆"的真相(chat.py)
SYSTEM_PROMPT = """你是「启明英语」培训机构的在线助教,名字叫小E。
你的职责:
1. 用中文答疑,涉及英语的部分保留英文原文并给出中文说明
2. 回答控制在 3 句话以内,老师用它备课,不需要客套话
3. 遇到不确定的语法点,直接说"这个我不确定",不要编规则
你的语气:像带了一年班的助教,直接、具体、不绕弯。
"""
def build_messages(
question: str,
history: list[tuple[str, str]] | None = None,
system: str = SYSTEM_PROMPT,
) -> list[SystemMessage | HumanMessage | AIMessage]:
"""把一次提问组装成模型认识的消息列表。
history : [(用户说的话, 助手回的话), ...],按时间从早到晚排
"""
# system 永远排第一
messages: list[SystemMessage | HumanMessage | AIMessage] = [SystemMessage(content=system)]
# 历史轮次拆成 user / assistant 交替的形式
for user_text, assistant_text in history or []:
messages.append(HumanMessage(content=user_text))
messages.append(AIMessage(content=assistant_text))
# 最后才是本次提问
messages.append(HumanMessage(content=question))
return messages
def ask(question: str, llm: BaseChatModel, history=None) -> str:
"""问一句,拿一句答案。
参数里接收 llm 而不是自己创建,这是"依赖注入":
谁调用谁负责传模型进来,测试时塞假模型就完事了。
"""
messages = build_messages(question, history=history)
response = llm.invoke(messages)
# 模型返回的是 AIMessage 对象,文本在 .content 里
return response.content
跑一下这个函数,看看多轮对话到底长什么样:
msgs = build_messages(
'那过去式呢?',
history=[('I have finished my homework this morning 对吗?',
'不对,this morning 是过去时间,要用一般过去时。')],
)
真实输出:
1. [SystemMessage] 你是「启明英语」培训机构的在线助教,名字叫小E。 你的职责: 1. 用中文答疑,涉及英语的
2. [HumanMessage] I have finished my homework this morning 对吗?
3. [AIMessage] 不对,this morning 是过去时间,要用一般过去时。
4. [HumanMessage] 那过去式呢?
共 4 条消息
请把这张图记牢:所谓的对话记忆,不是一个存在服务端的聊天记录,而是你本地的一个列表,每次请求原封不动再发一遍。 到第 3 篇讲上下文裁剪时,你才会明白为什么账单会失控、为什么聊久了模型会"失忆"。
另外注意 ask() 的写法:它接收 llm 作为参数,而不是自己 build_llm()。这叫依赖注入,看起来多此一举,但它是整个项目能被测试的前提——测试时塞进假模型,一个字都不用改。
六、第四步:先用 LangGraph 打个底(graph.py)
有同学问我:LangChain 和 LangGraph 到底什么关系?
一句话区分:LangChain 管"怎么调模型",LangGraph 管"好几步之间怎么流转"。 单轮问答看不出区别,一旦要做"查词典 → 判断要不要继续 → 组织答案"这种循环,手写 while 循环很快就会变成一坨面条。
第 1 篇先搭个只有单节点的空壳,后面加节点就是填空题:
from langchain_core.language_models import BaseChatModel
from langgraph.graph import END, START, StateGraph
from langgraph.graph.message import MessagesState
from chat import build_messages
def build_graph(llm: BaseChatModel) -> StateGraph:
"""搭一个只有单个节点的图:进来 → 问模型 → 出去。
MessagesState 是 LangGraph 内置的状态结构,
本质就是一个带 messages 键的字典,
并且约定:节点返回的消息会"追加"而不是"覆盖"。
"""
def call_model(state: MessagesState) -> dict:
"""节点函数:签名固定是"进状态,出字典"。
返回的键必须和状态里的键同名,
LangGraph 拿到后按既定规则合并回去。
"""
response = llm.invoke(state["messages"])
# 返回列表,LangGraph 会追加到历史里
return {"messages": [response]}
graph = StateGraph(MessagesState)
graph.add_node("assistant", call_model) # 注册节点
graph.add_edge(START, "assistant") # 入口指向这个节点
graph.add_edge("assistant", END) # 这个节点结束就收工
return graph.compile()
def run_graph(question: str, llm: BaseChatModel) -> str:
"""用图的方式跑一次问答。"""
app = build_graph(llm)
result = app.invoke({"messages": build_messages(question)})
# result 是完整状态;[-1] 是最后一条,也就是助手的回答
return result["messages"][-1].content
MessagesState 的关键约定我标在图上了:节点返回列表,LangGraph 是追加到历史,而不是覆盖。 这是新手最常写错的地方——返回 dict 覆盖会导致历史丢失,模型每次都当你在问第一句。
七、跑起来:真实输出
配好依赖后,我把 .env 留空(不配 Key)直接跑演示脚本:
==========================================================
StudyBuddy · 第 1 篇:环境与首次调用
==========================================================
接口地址:https://api.deepseek.com
使用模型:deepseek-flash
API Key :未配置 → 本次走离线演示
>>> 提问:学生写了 I have finished my homework this morning,哪里不对?
<<< 回答:时态用错了。this morning 是明确的过去时间,不能用现在完成时,
改成 I finished my homework this morning。
>>> 提问:在机场问登机口怎么走,怎么说比较自然?
<<< 走 LangGraph 图:最自然的说法是 Could you tell me where Gate 23 is?
加 Could 比直接 Where is 更礼貌。
离线模式下答案是预置的,为的是先把链路跑通;
把 DEEPSEEK_API_KEY 写进 .env,同样的代码就能拿到真实回答。
链路由此证明是通的:两条不同路径(直接 ask 和走 LangGraph 图)拿到了同一个模型的回答。 你把 .env 里的 Key 填上,不改一行代码,答案就变成真实大模型给的。
再跑测试:
platform darwin -- Python 3.13.12, pytest-9.1.1, pluggy-1.7.0
plugins: langsmith-0.14.12, anyio-4.15.1
collecting ... collected 15 items
tests/test_chat.py::test_system_message_always_first PASSED [ 6%]
tests/test_chat.py::test_last_message_is_the_question PASSED [ 13%]
tests/test_chat.py::test_history_is_expanded_in_order PASSED [ 20%]
tests/test_chat.py::test_ask_returns_model_text PASSED [ 26%]
tests/test_chat.py::test_ask_really_sends_history PASSED [ 33%]
tests/test_graph.py::test_graph_has_expected_node PASSED [ 40%]
tests/test_graph.py::test_graph_returns_last_message PASSED [ 46%]
tests/test_graph.py::test_graph_appends_not_overwrites PASSED [ 53%]
tests/test_llm.py::test_offline_mode_returns_usable_model PASSED [ 60%]
tests/test_llm.py::test_missing_key_raises_actionable_error PASSED [ 66%]
tests/test_llm.py::test_offline_mode_never_touches_network PASSED [ 73%]
tests/test_settings.py::test_defaults_point_to_official_endpoint PASSED [ 80%]
tests/test_settings.py::test_missing_key_is_not_confidential PASSED [ 86%]
tests/test_settings.py::test_read_env_file PASSED [ 93%]
tests/test_settings.py::test_settings_is_frozen PASSED [100%]
============================== 15 passed in 2.28s ==============================
15 个用例全绿,全程离线、零花费。 这条线是我给自己定的验收标准,也是这篇教程对你最硬的承诺:后面每一篇的代码都必须这样——你可以不花钱就把测试跑通。
其中我最看重那个 test_ask_really_sends_history。一开始我图省事写了个占位断言,等于自我安慰。重写后用一个会记录入参的假模型,真正断言"历史确实被塞进了请求"。写测试最怕的就是断言了一个恒真的东西。
八、踩过的坑,先替你填了
- which python 还是系统路径:说明虚拟环境没激活。每条新开的终端都要 source .venv/bin/activate,激活成功提示符前有 (.venv)。
- ModuleNotFoundError: No module named 'chat':直接 python demo.py 找不到 src/ 下的模块。我在 demo.py 头部加了把 src 塞进 sys.path 的三行;规范做法是写成可安装的包,但那要引入打包工具,第 1 篇先不增加负担。
- 镜像源 403 / 超时:清华源这次对 langchain-deepseek 返回 403,换阿里云源一次成功。换源重试是标准操作,不用怀疑人生。
- 模型名 404:确认你用的是 deepseek-flash,不是已退役的 deepseek-chat。
- base_url 多加了 /v1:DeepSeek 用根路径,加了会报莫名其妙的错。
- Key 写进了代码还推了仓库:立刻去平台删除重建。亡羊补牢也要做,公开仓库的 Key 几分钟就会被扫走。
- pytest 收集不到用例:检查是不是缺 pytest.ini,或者文件名没写成 test_*.py。
九、这一篇留下了什么
一个配了 Key 就调真模型、不配 Key 也能跑通全流程的工程骨架,加上 15 个离线可跑的测试。四个模块分工清楚:settings 管配置、llm 管模型通路、chat 管消息、graph 管编排。
下一篇我们解决一个更刺眼的问题:同样是这句 system prompt,为什么你的助教说话像换了个人? 我会带你把 Prompt 拆成可配置模板,讲清 temperature、少样本示例、思维链分别管什么,并用一个真实的"作文批改"场景让你看见改一个参数答案能差多远。
最后一句:环境配不稳,不是因为你笨,是因为没人把"为什么"讲给你听——把为什么弄明白,那些报错你自己就能读懂了。
本文由 AI图鉴自动抓取并翻译,版权归原作者所有;如需查看原文请点下方链接。
I took the trainees to the AI project, and of the people who hung up on the first day, seven out of ten died not in code, but in environment.
The typical scene is as follows: the lesson says pip install xx, he's loaded, he's running wrong, he says he can't find the package; he loads it again, he goes to the whole place, he tops the other project; he goes online and he sees people say he's using root, plus sudo, he's running out, he can't turn on the next day. Three nights back and forth, no interest, no one.
This one I'm not going to teach you to copy these lines of command. And I'm going to tell you why I did it -- what the virtual environment was, why the API Key couldn't write into the code, how.env was read, what happened on the Internet with a question and answer. You understand that the code in the next nine pages is not going to be "Running, Break."
And at the end of this story, you'll have a real run-off project: Key, with Key, with DeepSeek, with Key, with the whole chain running and all green.
I. Literacy first: five concepts, not all black boxes behind them
1. Virtual environment: one separate room per project
You might run several Python projects at the same time. Project A requires requests 2.28, project B requires requests 2.32, and only one version can be left in the system, so there must be a collapse.
Virtualenv does a simple thing: copy a Python interpreter and an empty space-packages directory under the.venv/ folder. Activate it, you use python and pip to point to this folder, and the bags lie in it. It's not a simulator, it's not a container, it's a isolated catalogue.
How do you judge if you've been activated? See if there's any (.venv) in front of the command line hint, or run:
which python
# Output should be: / Your project path/.venv/bin/python
# If the output is /usr/bin/python or /opt/homebrew/bin/python, indicate that the system is still in use
2. Pip: Moving code from other people to your machine Move!
Python can fire, half in ecology. You have to send an HTTP request, you can write it in someone else without writing yourself.
Requirements.txt is the solidification of this shopping list: write down the package and the version that you used for your project and replace it with a machine.txt. I stepped on a pit this time, and I just said, "The direct company PyPI in the country is often slow, changing the mirrors like I ate 403 -- there's a delay in synchronizing the mirror sources, and some cold doors are not full." Finally, Alibaba Cloud was a success. It's not genus. The switch source retry is standard.
pip install -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple/
3. API Key: A key to be billed
LLM isn't free, the platform depends on Key to identify who you are and who you're taking money from. So:
- Never die in the code. Once the code is pushed to the open warehouse, the reptile can sweep your Key in a few minutes, and your balance is gone.
- Put.env file and write.env into.gitignore. The code is read from the environment variable (.env is a convenient local development).
Why would Key lose? Not hackers are smart, but forget to add.gitignore. This is the most common one-time loss I've ever seen.
HTTP requests all the secrets of interaction with JSON: Model
The transfer of LLM is essentially an HTTPS request: you POST part of JSON's past, the server goes back to JSON. No.
The request is about this long (DeepSeek is Open AI compatible format):
{
"model": "deepseek-flash",
"messages": [
== sync, corrected by elderman == @elder man
"Role": "user", "content": "Is this the right sentence?"
],
"temperature": 0.7
}
There are three types of role: system identity, user is what the user says, asistant is modeled back. One last point: the service does not save anything. You think "multi-cycle conversation" is every time you re-transmit everything. This will kill us when we talk about context — it's the source of Token's out of control.
5. Model name change: the old curriculum will be straightforward 404
This one must be brought out alone, because when I looked at the official files, I confirmed something that many people did not know:
The names Deepseek-chat and Deepseek-reasoner were retired on July 24, 2026.
Now the right thing is:
If you follow the 2025 blog, Deepseek-chat, you get the cold 404. So I developed a habit of taking a look at the model name in the official file every time I started a new project, which was a lot cheaper than an hour later.
Another small pit: DeepSeek's base url is https://api.deepseek.com, not behind /v1. A lot of people are accustomed to using route prefixes and then get a bunch of mistakes that they can't understand.
II. What does the project look like?
I organized it in a "one file" way so you didn't have to flip through hundreds of lines when you changed a piece:
01-Environment and First Call/
Ideas -.env.example # Configure Sample; copy in.env and fill in your own Key
.gitignore # must contain.env and.venv/
-Requirements.txt
Tell Pytest where to find the test.
Ideas-demo.py
├── src/
│-settings.py # Configure centralized reading
Imm.py # Model factory (real/false)
# Scrambles and questions #
│-gram.py # Minimal LangGraph
└-tests/ # Five test files, run offline
It depends on four: langchain-deepseek, langgraph, python-dotenv, pytest. My version this time is langchain-deepseek 1.1.1, langgraph 1.2.18, langchain-core 1.7.0, Python is 3.13.12.
III. First step: configuration in one place (settings.py)
beginner loves to write os.getenv ("DEEPSEEK API KEY") all over the code. When you change models, change addresses, and deploy to colleagues, you have to search the projects. Let's get it together. Let's get it done.
""Concentrate management: only read environment variables here."
from __future__ import annotations
import os
from dataclasses import dataclass
from dotenv import load_dotenv
# Open AI compatible portal for DeepSeek, address given in official documents
# Note: Not https://api.deepseek.com/v1, DeepSeek direct root path
DEEPSEEK_BASE_URL = "https://api.deepseek.com"
# Current Major Model 2026 (old deepseek-chat / deepseek-reasner retired)
DEFAULT_MODEL = "deepseek-flash"
@dataclass(frozen=True)
class Settings:
""A read-only configuration container.
#Frozen=True says it can't be changed when created.
Avoid the code anywhere sneaking out the model, checking for half a day.
"""
api_key: str = ""
base_url: str = DEEPSEEK_BASE_URL
model: str = DEFAULT_MODEL
@property
def has_key(self) -> bool:
""Wasn't it with Key?"
return bool(self.api_key)
def load_settings(env_file: str | None = None, override: bool = False) -> Settings:
Read the configuration.
env file: specify the.env file path and pass the None default to the root directory.env
Override: True means the value in.env overwrites the existing system environment variable.
"""
# load dotenv will write #.env keys to os.environ
# Before this line is executed, os.getev cannot read.env
load_dotenv(env_file, override=override)
return Settings(
api_key=os.getenv("DEEPSEEK_API_KEY", "").strip(),
base_url=os.getenv("DEEPSEEK_BASE_URL", DEEPSEEK_BASE_URL).strip(),
model=os.getenv("DEEPSEEK_MODEL", DEFAULT_MODEL).strip(),
)
Two details are worth noting:
- @dataclass (frozen=True): frozen let the example read only. It's not a show-off -- it's spread through the code, and once it's changed quietly by a function, you can find out about life.
-.strip():.env file is an "Key's invalid" with an extra space at the end of the line, and I've seen this kind of problem too many times, strip for a second.
Step 2: Model factory, with a free exit (llm.py)
This is what I think is the most important piece of design in this book. It solves a real problem: the author of the curriculum cannot assume that the reader has Key.
from langchain_core.language_models import BaseChatModel
from langchain_core.language_models.fake_chat_models import FakeListChatModel
from langchain_deepseek import ChatDeepSeek
from settings import Settings
DEFAULT_TEMPERATURE = 0.7
DEFAULT_TIMEOUT = 60
def build_llm(
settings: Settings,
*,
temperature: float = DEFAULT_TEMPERATURE,
timeout: int = DEFAULT_TIMEOUT,
offline_responses: Sequence[str] | None = None,
) -> BaseChatModel:
"""creates a chat model.
The return value type is BaseChatModel, not a specific category.
It's called "programming for interfaces": the caller is only.invoke() working.
It doesn't matter whether it's real or fake.
"""
# Route one: offline fake model for testing and demonstration
if offline_responses is not None:
# FakeListChatModel will return in the order of the list and start over
return FakeListChatModel(responses=list(offline_responses))
# Road II: Real DeepSeek
if not settings.has_key:
raise RuntimeError(
"Does not read DEEPSEEK API KEY. I'm sorry.
Two solutions:
" 1. Build the.env file on the root of the project and write a line of DEEPSEEK API KEY=sk-xx\n"
" 2. Or call build llm (..., offline responses=['...'"]) on offline."
)
return ChatDeepSeek(
model=settings.model,
api_key=settings.api_key,
api_base=settings.base_url,
temperature=temperature,
timeout=timeout,
max retries=2, # Automatic retry when the network is shaking
)
Three roads, by priority:
- Passed offline responses, fake models, no Internet requests, no money.
- Yes, Key, real model.
Key didn't give offline answers.
The third is what I did. By default, the lack of Key throws a bunch of unconvinced authentication anomalies, and beginner goes straight through. "What should you do?" It's a completely different experience:
has_key = False
RuntimeError:
No DEEPSEEK API KEY.
Two solutions:
1. Build a.env file, write a line of DEEPSEEK API KEY=sk-xx
Or call bild llm (..., offline responses=['...'.']) on offline.
What's that? It controls the randomity of answers. 0 Close to certainty (almost the same answer to the same question), 1 or more. The grammar scenario of teaching English, which I suggest, is down to 0.3 -- you don't want the assistant to speak the same way.
V. Step 3: Spelling news, seeing the truth about memory (chat.py)
SYSTEM PROMPT = ""You're an online assistant at the Enlighten English training facility called Little E.
Your duties:
1. Retain in Chinese the English text and provide an explanation in Chinese
2. The answer is contained in three words, and the teacher uses it to prepare the class, without the need for a guest speech.
"I'm not sure."
Your tone: like a one-year assistant, straight, concrete, not bending.
"""
def build_messages(
question: str,
history: list[tuple[str, str]] | None = None,
system: str = SYSTEM_PROMPT,
) -> list[SystemMessage | HumanMessage | AIMessage]:
""Assemble a question into a list of messages that model.
History: [(User's words, helper returns], line up from morning to night]
"""
# System always comes first
messages: list[SystemMessage | HumanMessage | AIMessage] = [SystemMessage(content=system)]
# History turns into user / alternate
for user_text, assistant_text in history or []:
messages.append(HumanMessage(content=user_text))
messages.append(AIMessage(content=assistant_text))
# Finally, this question
messages.append(HumanMessage(content=question))
return messages
def ask(question: str, llm: BaseChatModel, history=None) -> str:
"Ask," take an answer.
Ilm is accepted in parameters instead of being created. It's dependent injection:
Whoever calls the model, it's over.
"""
messages = build_messages(question, history=history)
response = llm.invoke(messages)
# The model returns the AIMESSAGE object, text is in.content
return response.content
Run this function and see what multiple rounds of conversation look like:
msgs = build_messages(
"What about the past?"
I've finished my homework this morning, right? '...
'No, this morning is past time to use normal past times. I don't know.
)
Real output:
1. [SystemMessage] You are an online assistant at the Enlightenment English Training Institute, called Little E. Your duty: 1. Answering questions in Chinese, involving English
[HUMAN MESSAGE] I have finished my homework this morning, right?
[AIMESSAGE] No, this morning is the time of the past to use the normal time of the past.
What about the past?
4 messages
Please keep this image firmly in mind: the so-called conversational memory is not a chat record with a service, but a list of your locals, each time the request is left intact. It's only when you're talking about context cropping in the third chapter that you understand why the bill is out of control and why the model is "remembered."
Also note the writing of ask(): it accepts llm as a parameter, not its own bild llm(). It's called relying on injection, which seems to add to it, but it's a prerequisite for the entire project to be tested — to be inserted into a false model at the test, without changing a word.
VI. Step 4: use LangGraph first.
I was asked what was the relationship between Lang Chain and Lang Graph?
One thing: LangChain says, "How to fix a model," LangGraph says, "How to flow between steps." There's no difference in a single question-and-answer cycle, and once you've got a dictionary to figure out if you want to continue with the organizational answer, handwritten while the loop becomes a piece of noodles.
Title 1 begins with a shell with a single node, followed by a node to fill the blanks:
from langchain_core.language_models import BaseChatModel
from langgraph.graph import END, START, StateGraph
from langgraph.graph.message import MessagesState
from chat import build_messages
def build_graph(llm: BaseChatModel) -> StateGraph:
""Play a single node: come in and ask the model to go out.
Messagesstate is the state structure embedded in LangGraph.
The essence is a dictionary with messages.
And it was agreed that the return of nodes would "add" rather than "cover".
"""
def call_model(state: MessagesState) -> dict:
Node function: The signature is fixed as "into state, out of dictionary."
The key returned must have the same name as the key in the state.
LangGraph gets it and merges it back in accordance with the established rules.
"""
response = llm.invoke(state["messages"])
# Return the list that LangGraph will add to history
return {"messages": [response]}
graph = StateGraph(MessagesState)
# Register node
# The entrance points to this node
# When this node is over
return graph.compile()
def run_graph(question: str, llm: BaseChatModel) -> str:
""Run one question and answer in the way of the drawing."
app = build_graph(llm)
result = app.invoke({"messages": build_messages(question)})
# Result is complete;[-1] is the last, the assistant's answer
return result["messages"][-1].content
The key agreement for Messages State is marked on the map: node returns the list, LangGraph is added to history, not over. This is where beginner is most often written wrong -- return dict overlay leads to loss of history, and the model is always when you ask the first one.
VII. RUN UP: REAL OUTCOME
I left.env empty and ran straight to the script:
==========================================================
Study Buddy. Part 1: Environment and First Call
==========================================================
Interface address: https://api.deepseek.com
Use model: Deepseek-flash
API Key: Unconfigured offline presentation
I have finished my homework this morning, what's wrong?
Answer: The pattern is wrong. This morning is a clear past.
I finished my homework this morning.
Question: Ask the gate at the airport where to go, how to say it is natural?
Could you tell me where Gate 23 is?
Plus would be more polite than direct where is.
The answer under the offline model is preset to run the link first;
Write DEEPSEEK API KEY in.env, the same code will get the real answer.
The link thus proved to work: two different paths (direct ask and the LangGraph) received answers from the same model. You fill in the.env Key, or change the code, the answer becomes the real LLM.
Rerun test:
platform darwin -- Python 3.13.12, pytest-9.1.1, pluggy-1.7.0
plugins: langsmith-0.14.12, anyio-4.15.1
collecting ... collected 15 items
tests/test_chat.py::test_system_message_always_first PASSED [ 6%]
tests/test_chat.py::test_last_message_is_the_question PASSED [ 13%]
tests/test_chat.py::test_history_is_expanded_in_order PASSED [ 20%]
tests/test_chat.py::test_ask_returns_model_text PASSED [ 26%]
tests/test_chat.py::test_ask_really_sends_history PASSED [ 33%]
tests/test_graph.py::test_graph_has_expected_node PASSED [ 40%]
tests/test_graph.py::test_graph_returns_last_message PASSED [ 46%]
tests/test_graph.py::test_graph_appends_not_overwrites PASSED [ 53%]
tests/test_llm.py::test_offline_mode_returns_usable_model PASSED [ 60%]
tests/test_llm.py::test_missing_key_raises_actionable_error PASSED [ 66%]
tests/test_llm.py::test_offline_mode_never_touches_network PASSED [ 73%]
tests/test_settings.py::test_defaults_point_to_official_endpoint PASSED [ 80%]
tests/test_settings.py::test_missing_key_is_not_confidential PASSED [ 86%]
tests/test_settings.py::test_read_env_file PASSED [ 93%]
tests/test_settings.py::test_settings_is_frozen PASSED [100%]
============================== 15 passed in 2.28s ==============================
15 full green, full offline, zero cost. This line is the acceptance criteria I gave myself, and it's the strongest commitment of the curriculum to you: it has to be the code in the back -- you can run the tests without paying.
I value that one most. At first, I wrote a claim to the throne, which was self-consolation. rewrite it with a fake model that records participation, which really asserts that "history is actually being put in demand." The most scary thing about writing tests is to assert that something is real.
I'll fill the hole.
- which python or system path: it means the virtual environment is not active. Every newly opened terminal requires source.venv/bin/activate, which is available before the successful hint is activated (.venv).
- ModuleNotFundError: No mobile named 'chat': directly python demo.py could not find the module under src/. I added three rows to the head of demo.py to insert src into sys.path; the norm is that it is written as a package that can be installed, but that introduces a packing tool, with no additional burden for the first part.
- Mirror source 403/timeout: Qinghuagen this time returned 403 to langchain-deepseek for Alibaba Cloud source once. The alternative source retry is a standard operation, without doubt.
- Model name 404: Make sure you use Deepseek-flash, not retired Deepseek-chat.
- base url added /v1: DeepSeek uses root path to add a strange error.
- Key wrote the code and pushed the warehouse: Go to the platform and delete reconstruction. We'll have to fix the dead. Key from the open warehouse will be swept away in a few minutes.
- Pytest does not collect examples: check if pytest.ini is missing or the file name is not written.
What's left of this?
One, with Key, is a real model, and Key doesn't fit the engineering skeleton that runs through the process, plus 15 offline running tests. The division of the four modules is clear: settings configuration, llm pipe model access, chat pipe news, graph layout.
The next one we're going to solve is a more dramatic problem: Same sentence, stym prompt. Why does your assistant talk like a different person? I'm going to take you to break Prompt into a configuration template, tell you how far it goes to change a parameter.
Last sentence: The environment is unstable, not because you're stupid, because no one tells you "why" -- to understand why you can read the wrongs.
Auto-fetched and translated by AI Atlas. All rights belong to the original author.