本文来自《AI 应用开发课程》月份 1 课程文档,已整理为网站文章版本。
学习目标
学完本节后,你应当能够:
- 理解一次 LLM 请求的最小组成。
- 了解 DeepSeek 的 OpenAI 兼容接口使用方式。
- 用
httpx发出一次最小聊天请求。 - 理解为什么课程里要做“DeepSeek 主线 + 通用抽象”。
前置知识
- 已完成 Python 基础与工程化模块
1. 一次 LLM 请求最少包含什么
一次聊天类 LLM 请求,至少包含以下内容:
modelmessages- 可选的生成参数,例如
temperature
最核心的是 messages。它不是单纯的一句话,而是一组对话消息。
2. OpenAI 兼容接口是什么意思
DeepSeek 官方提供与 OpenAI 风格兼容的聊天接口。你可以把它理解为:
- 请求结构和多数 OpenAI Chat Completions 风格类似
- 可以复用相同的数据结构设计
- 可以为后续切换模型供应商留出空间
但“兼容”不等于“一切完全相同”。因此课程中仍然要求把厂商差异收口到 client 层。
3. 当前课程采用的默认配置
本月课程默认采用以下配置:
DEEPSEEK_API_KEY=你的密钥
DEEPSEEK_BASE_URL=https://api.deepseek.com
DEEPSEEK_MODEL=deepseek-chat
说明:
- 课程写作时,DeepSeek 官方文档仍然将
https://api.deepseek.com作为主要兼容接口地址。 deepseek-chat适合作为月份 1 的默认模型,因为它更符合普通聊天与结构化输出场景。
4. 最小请求结构
{
"model": "deepseek-chat",
"messages": [
{"role": "system", "content": "你是一个严谨的研发助手。"},
{"role": "user", "content": "请介绍一下 FastAPI。"}
],
"temperature": 0.2
}
5. 用 httpx 发送最小请求
示例代码:
import os
import httpx
from dotenv import load_dotenv
load_dotenv()
async def call_deepseek() -> str:
api_key = os.getenv("DEEPSEEK_API_KEY")
base_url = os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com")
model = os.getenv("DEEPSEEK_MODEL", "deepseek-chat")
if not api_key:
raise ValueError("DEEPSEEK_API_KEY 未配置")
payload = {
"model": model,
"messages": [
{"role": "system", "content": "你是一个严谨的研发助手。"},
{"role": "user", "content": "请介绍一下 FastAPI。"},
],
"temperature": 0.2,
}
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
f"{base_url}/chat/completions",
headers=headers,
json=payload,
)
response.raise_for_status()
data = response.json()
return data["choices"][0]["message"]["content"]
执行入口:
import asyncio
if __name__ == "__main__":
print(asyncio.run(call_deepseek()))
6. 代码讲解
为什么不用 SDK 开始
因为本周目标是先理解请求本质:
- URL
- headers
- body
- 响应 JSON
先掌握这一层,再决定是否接 SDK 或框架。
为什么 response.raise_for_status() 很重要
因为它会在 4xx/5xx 时尽早抛异常,避免你误把错误响应当成正常结果继续解析。
为什么把 base_url、api_key、model 都做成配置
因为课程要求你形成“模型提供方可替换”的工程习惯。
7. LLM 请求生命周期图
flowchart LR
A["读取 .env"] --> B["构造 messages"]
B --> C["组织请求体 JSON"]
C --> D["发送 HTTP 请求"]
D --> E["收到响应 JSON"]
E --> F["提取 answer"]
F --> G["记录日志/成本/错误"]
8. 实操任务
- 在
llm_api_lab或prompt_lab中新建deepseek_demo.py - 复制并运行最小请求
- 把 system prompt 改成“你是 Python 导师”,观察输出变化
- 故意把 API Key 改错,观察报错
9. 自测题
- 为什么课程先用
httpx而不是一开始就用 SDK? - OpenAI 兼容接口给工程结构带来的核心好处是什么?
- 为什么
messages比单一 prompt 字符串更适合真实对话?
10. 作业与验收
作业:
- 封装一个最小
LLMClient.generate()方法,只完成普通文本返回即可。
验收标准:
- 能返回模型文本
- 配置来自
.env - 网络错误和鉴权错误能清晰暴露
11. 常见错误
- 把
base_url写成错误地址 - 忘记附带
Authorization头 - 错把响应字段名理解为固定不变且无需检查
12. 本章与前文关系
前面的 Python 基础与工程化章节,目的是让你具备:
- 稳定本地环境
- 项目结构
- 配置读取
- 数据建模
- 测试意识
现在才真正进入月份 1 的“AI 应用开发”核心:模型调用。
但请注意,本章的目标不是“会调一次接口”,而是建立完整请求链路直觉:
- 请求发到哪里
- 头里带什么
- body 长什么样
- 响应怎么拆
- 出错时在哪里报
13. 本章在研发助手项目中的位置
研发助手项目中的 llm_client.py,本质上就是本章内容的工程化版本。无论后面你做:
- 总结 diff
- 生成 commit message
- 抽取待办项
都要先经过本章建立的调用链路。
14. 基于官方文档确认的 DeepSeek 兼容接口事实
本课程写作时,DeepSeek 官方文档明确说明:
- 兼容 OpenAI 风格 API
- 主要
base_url为https://api.deepseek.com - 为兼容某些现有工具,也可使用
https://api.deepseek.com/v1 - 常见模型包括
deepseek-chat和deepseek-reasoner
这里有两个需要讲清的边界:
第一,兼容的是“接口风格”,不是“一切语义完全等同”
也就是说,你可以复用大体请求结构,但不能不看官方文档就默认所有参数、行为和高级功能完全一致。
第二,/v1 只是兼容路径,不代表模型版本
这点在官方文档里有明确说明。很多初学者会误以为 /v1 和模型版本强绑定,这是错误理解。
来源:
15. 一次模型调用的 anatomy
如果你只记“POST 一个 JSON”,很快就会在调试时迷路。更好的理解方式是拆成 5 层:
第一层:配置层
- API Key
- Base URL
- 默认模型名
第二层:输入层
messages- 生成参数,如
temperature
第三层:传输层
- HTTP 方法
- URL
- headers
- JSON body
第四层:响应层
- 状态码
- 响应 JSON
- 模型生成文本
第五层:应用层
- 日志
- 成本记录
- 错误处理
- 向上返回给 service 或 CLI
这 5 层分开理解,后面你才不会把所有问题都归因于“模型不稳定”。
16. 错误示例 vs 正确示例
错误示例:直接把调用逻辑写死在脚本里,不抽象边界
import httpx
import os
async def main():
response = await httpx.AsyncClient().post(
"https://api.deepseek.com/chat/completions",
headers={"Authorization": f"Bearer {os.getenv('DEEPSEEK_API_KEY')}"},
json={"model": "deepseek-chat", "messages": [{"role": "user", "content": "hello"}]},
)
print(response.json())
这段代码不是不能跑,但问题很明显:
- 配置没有收口
- 没有统一错误处理
- 没有请求边界抽象
- 后面很难复用到 FastAPI 或项目 service 层
正确示例:抽成最小 LLMClient
class LLMClient:
async def generate(self, messages: list[dict[str, str]]) -> str:
...
课程后续都将围绕这种边界去组织代码。
17. 完整文件级示例:app/clients/llm_client.py
"""月份 1 的最小 DeepSeek 客户端实现。"""
from __future__ import annotations
import os
from collections.abc import AsyncIterator
from typing import Any
import httpx
from dotenv import load_dotenv
load_dotenv()
class LLMClient:
"""封装月份 1 需要的最小模型调用能力。
当前阶段先聚焦普通文本生成和流式生成。
结构化输出和 Tool Calling 会在后续章节继续扩展。
"""
def __init__(self) -> None:
self.api_key = os.getenv("DEEPSEEK_API_KEY")
self.base_url = os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com")
self.model = os.getenv("DEEPSEEK_MODEL", "deepseek-chat")
if not self.api_key:
raise ValueError("DEEPSEEK_API_KEY 未配置")
@property
def headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
async def generate(
self,
messages: list[dict[str, str]],
temperature: float = 0.2,
) -> str:
"""执行一次普通文本生成。"""
payload = {
"model": self.model,
"messages": messages,
"temperature": temperature,
}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
f"{self.base_url}/chat/completions",
headers=self.headers,
json=payload,
)
response.raise_for_status()
data = response.json()
return data["choices"][0]["message"]["content"]
async def stream(
self,
messages: list[dict[str, str]],
temperature: float = 0.2,
) -> AsyncIterator[str]:
"""按 chunk 逐块返回文本。
这里为了教学简洁,只演示最小消费逻辑。
真正生产级实现还需要考虑更完整的事件格式和关闭逻辑。
"""
payload = {
"model": self.model,
"messages": messages,
"temperature": temperature,
"stream": True,
}
async with httpx.AsyncClient(timeout=30.0) as client:
async with client.stream(
"POST",
f"{self.base_url}/chat/completions",
headers=self.headers,
json=payload,
) as response:
response.raise_for_status()
async for line in response.aiter_lines():
if not line or not line.startswith("data: "):
continue
if line == "data: [DONE]":
break
yield line
18. 逐段解释这份完整示例
为什么 LLMClient 在初始化时就读配置
因为后面所有调用都依赖同一组配置。把它们放进对象初始化,可以减少上层逻辑重复传参。
为什么 headers 单独抽成属性
因为:
- 逻辑重复
- 易于统一修改
- 更利于阅读
为什么先实现 generate()
因为它最简单,也最容易建立调用链路直觉。后面的 structured_generate()、call_with_tools() 都是在它的基础上扩展。
为什么 stream() 返回 AsyncIterator[str]
这正是为了让上层可以:
- 在 CLI 中逐块打印
- 在 FastAPI 中逐块透传
这一步已经在为后面的服务化章节铺垫。
19. 从最小实现到工程实现的递进
你现在应该把模型调用理解成一个逐步升级的过程:
第一步:普通文本生成
理解请求和响应最小结构。
第二步:结构化输出
让模型为程序返回稳定结构。
第三步:流式输出
让结果边生成边消费。
第四步:工具调用
让模型不只输出文本,还能触发程序能力。
这也是后面几个章节的组织逻辑。
20. 调试与排错:本章最常见问题
问题一:401 Unauthorized
优先检查:
.env是否加载- API Key 是否为空
Authorization是否是Bearer <key>形式
问题二:404 或路径错误
优先检查:
base_url是否为https://api.deepseek.com- 是否拼成了错误路径
- 是否误把
/v1当作必须路径
问题三:响应解析报错
优先检查:
- 状态码是否成功
- 响应 JSON 是否真的包含
choices - 是否误把错误响应当成正常响应
21. 本章完成后你应该具备的能力
完成本章后,你应当做到:
- 能独立描述一次 LLM 请求的完整链路。
- 能写出最小
LLMClient.generate()。 - 能解释 DeepSeek 的 OpenAI 兼容接口意味着什么、不意味着什么。
- 能初步读懂流式返回和普通返回的差异。
22. 如果你卡在这里,先回看哪几章
- 异步看不懂:回看 05-asyncio基础.md
- 配置读取混乱:回看 02-ruff日志env配置.md
- 项目结构不稳:回看 01-uv与项目初始化.md
23. 从本章过渡到下一章的桥接说明
接下来进入 02-消息结构与上下文管理.md。
因为你现在已经会“发请求”,下一步必须搞清楚“发的到底是什么”。也就是:为什么 messages 不是一段普通字符串,为什么多轮对话需要手动维护历史,以及为什么消息结构是后面 Tool Calling 和 FastAPI schema 的共同基础。