第一课中使用OpenAI调用模型DeepSeek 代码,作为基准测试代码:
py
from openai import OpenAI
from dotenv import load_dotenv
import os
# 初始化创建 OpenAI 对象,从.env 中获取 API KEY
load_dotenv(override=True)
api_key = os.getenv("API_KEY")
base_url = os.getenv("BASE_URL")
client = OpenAI(api_key=api_key, base_url=base_url)
def main():
prompt = "Hi"
print(call_model([{"role": "user", "content": prompt}]))
# 调用大模型
def call_model(messages):
response = client.chat.completions.create(
model="deepseek-v4-pro",
messages=messages,
stream=False,
reasoning_effort="high",
extra_body={"thinking": {"type": "enabled"}}
)
return response.choices[0].message.content
if __name__ == "__main__":
main()
GPT 模型原理
GPT(Generative Pre-trained Transformer)就是上一课讲的 Decoder-Only Transformer。
一个 GPT 模型从出生到能用,经过三个阶段:
text
预训练(Pre-training) → 微调(Fine-tuning) → 对齐(RLHF)
"学语言" "学任务" "学做人"
类比:
上完所有学校的课 学某个专业技能 学会什么该说什么不该说
1. 预训练:学语言
把互联网上的海量文本(维基百科、书籍、代码、论坛)喂给模型,任务很简单------给定前面的词,预测下一个词。
text
输入: "今天天气真"
预测: "好" ← 模型猜下一个 token
输入: "今天天气真好,我们出去"
预测: "玩" ← 再猜下一个
几万亿个 token 反复猜下来,模型学会了语法、常识、推理、代码......一切人类写下来的东西。但这阶段模型只会"续写",不懂"对话"。
2. 微调:学任务
收集大量"人类觉得好的问答对",教模型:
text
训练数据示例:
{"input": "什么是重力?", "output": "重力是地球对物体的吸引力,方向竖直向下......"}
{"input": "写一首关于春天的诗", "output": "春风又绿江南岸......"}
预训练模型学会了续写,微调让它学会了回答问题 和遵循指令。
3. RLHF:学做人
RLHF = Reinforcement Learning from Human Feedback(基于人类反馈的强化学习)。
微调后的模型可能还是会胡说八道、输出有害内容。RLHF 让人类标注员给模型的多个回答打分:
text
问:"怎么制作炸弹?"
答1:"我无法提供这类信息......" ← 好(+1)
答2:"首先你需要......" ← 差(-1)
模型学会:安全 > 道德 > 有用 > 准确。这就是为什么 ChatGPT 不会教你怎么做坏事。
为什么 Decoder-Only 赢了
上一课讲了 Encoder-Decoder 和 Decoder-Only 两种 Transformer。2023 年后所有主流大模型(GPT-4、Claude、Llama、Qwen、DeepSeek)都是 Decoder-Only。
| Encoder-Decoder(BERT、T5) | Decoder-Only(GPT 系列) | |
|---|---|---|
| 原理 | 先理解再生成,两段式 | 直接自回归生成 |
| 擅长 | 理解、分类、翻译 | 生成、对话、推理 |
| 扩展性 | 差,加参数成本高 | 好,Scaling Law 友好 |
| 代表 | BERT(Google,2018) | GPT-4、Claude、Llama |
核心原因:Scaling Law。Decoder-Only 模型参数量和数据量翻倍,能力稳定提升。Encoder-Decoder 架构扩展起来没那么顺。
为什么流程一样,模型差距却很大?三个阶段是公开的,但每一步的"用料"天差地别:
- 预训练数据 --- 精心清洗的高质量数据 vs 互联网随便爬的噪声数据。垃圾进,垃圾出。
- 算力 ---
Scaling Law决定了参数越多、数据越多、能力越强,但这背后是万卡 GPU 集群 vs 几百张卡的差距,训练成本差几个数量级。 - 指令微调(SFT) --- 高质量问答对是关键。用博士级标注员写的数据 vs 用 GPT 生成的"二手数据",模型上限完全不同。
- RLHF 标注质量 --- 专业团队 + 持续 A/B 迭代 vs 外包标注标准不一。标注员的水平决定模型"品味"的上限。
- 训练细节 --- 分词策略、学习率调度、数据配比、过拟合控制、优化器选择......全是踩坑踩出来的经验,论文里不会写全。
需要不断的去试错、去踩坑,才能有更富的经验,实现架构创新、策略创新。
Temperature 与 Top-p:控制"创造力"
模型预测下一个 token 时,会给每个候选词一个概率。Temperature 和 Top-p 控制从这些候选词中怎么选。
text
模型预测下一个词的概率分布:
"好" → 60%
"棒" → 20%
"不错" → 10%
"差" → 5%
"烂" → 5%
Temperature = 0(确定性最高):永远选概率最高的 → "好"
Temperature = 1(默认):按原始概率随机选 → 大概率"好",偶尔"棒"
Temperature = 2(疯狂):概率被拉平 → "好"和"烂"被选中的概率差不多
| 参数 | 作用 | 类比 |
|---|---|---|
temperature=0 |
每次输出几乎一样,最确定 | 前端 Math.floor(),结果固定 |
temperature=0.7 |
适度随机,推荐日常使用 | 有变化但不离谱 |
temperature=1.5+ |
非常随机,可能胡说八道 | Math.random() 全开 |
top_p=0.9 |
只从累计概率前 90% 的词中选 | 裁掉长尾低概率词 |
Temperature 调整概率分布的"陡峭程度",Top-p 则是直接裁剪候选词数量。它从概率最高的词开始累加,累加到 p 就截断,排在后面的词直接丢弃。
text
原始概率分布: "好"60% "棒"20% "不错"10% "差"5% "烂"5%
top_p = 0.8:
累加:60% + 20% = 80% → 够了,截断
候选只剩"好"和"棒",后面三个词被丢弃
→ 不管 Temperature 设多高,都选不到"烂"
top_p = 0.5:
累加:60% → 不够 → +20% = 80% → 超额,截断
候选还是"好"和"棒"(因为"好"自己不够 50%)
top_p = 1.0:
不截断,所有词保留
要怎么配置呢:
text
Temperature:调整每个候选词的"中奖概率"(改分布形状)
Top-p: 直接淘汰排名靠后的词(改候选数量)
类比:Temperature 是调骰子的重量分布,Top-p 是直接扔掉几个面
一般不要两个都设------OpenAI 建议调 Temperature,Top-p 保持默认 1:
- 写代码、翻译、JSON 提取 →
temperature=0(要确定性) - 写文案、讲故事、头脑风暴 →
temperature=0.7~1.0(要多样性) - 绝大多数场景不要超过 1.0
- Top-p 通常不动,除非你明确想限制候选词范围(比如只让模型在"常见词"里选)
Prompt Engineering(提示词工程)
Prompt 就是给模型的输入消息。Prompt Engineering 就是设计这条消息让模型输出你想要的结果。
基础原则
1. 说清楚要什么
text
❌ "写一个函数"
✅ "写一个 Python 函数,输入一个列表,返回去重后排序的新列表。包含 docstring 和示例。"
2. 给角色设定(System Prompt)
text
System: "你是一个资深 Python 后端工程师,擅长写可读性强、有测试的代码。"
User: "写一个二分查找函数"
模型就会以工程师的口吻回复,而不是助教或文档生成器。
3. 给示例(Few-shot)
text
User: "英文 → 中文:
hello → 你好
goodbye → 再见
apple → 苹果
cat → ?"
模型看到前三个例子,就知道你想要的翻译格式,直接输出 "猫"。
进阶模式
Chain-of-Thought(思维链)
让模型先想再答,推理能力显著提升:
text
❌ "买 3 个苹果(每个 2 元)和 5 个橘子(每个 3 元),给 50 元,找回多少?"
✅ "买 3 个苹果(每个 2 元)和 5 个橘子(每个 3 元),给 50 元,找回多少?请一步步思考。"
模型输出:
第1步:苹果 3×2 = 6 元
第2步:橘子 5×3 = 15 元
第3步:总共 6+15 = 21 元
第4步:50-21 = 29 元
答案:找回 29 元
对复杂推理(数学、逻辑、编程)效果显著。现代模型(Claude、GPT)内部已融合 CoT 能力,但对小模型加"一步步思考"仍然管用。
ReAct(推理 + 行动)
让模型边想边做,适合需要调用工具的 Agent 场景:
text
Thought: 用户想知道今天北京天气,我需要查天气 API
Action: 调用 get_weather(city="北京")
Observation: 北京 26°C,晴
Thought: 信息拿到了,直接回答用户
Answer: 今天北京晴天,26°C
Structured Output / JSON Mode
让 LLM 输出合法 JSON,而不是自由文本。这是 LLM 和前端的 API 契约------前端拿到结构化数据直接渲染,不用正则解析。
修改调用函数传递参数response_format={ 'type': 'json_object' },而且提示词里必须出现json字样。
py
# 修改调用参数,增加结构化输出参数
def call_model(messages):
# 加系统提示词,并且添加 JSON 提示
system_prompt="All result must be a json object"
messages.insert(0, {"role": "system", "content": system_prompt})
response = client.chat.completions.create(
model="deepseek-v4-pro",
messages=messages,
stream=False,
reasoning_effort="high",
# 结构化输出
response_format={
'type': 'json_object'
},
extra_body={"thinking": {"type": "enabled"}}
)
return response.choices[0].message.content
虽然这样可以输出结构化的结果,但是并不知道输出的结果字段、类型是什么。需要去做验证、判断。
字段名本身就是 Prompt
你给 JSON Mode 写输出要求时,不用把所有字段解释一遍------字段名起得好,模型自己就能猜对。
text
字段名自解释时,只写字段名就够了:
✅ "字段:name, phone, address" → 模型知道这是个人信息
✅ "字段:severity, line, issue, suggestion" → 模型知道这是代码审查
字段名模糊时,必须加一句说明:
❌ "字段:type, status, level, value" → type 是什么类型?status 有几个状态?
✅ "字段:type(bug | performance | style),status(open | fixed | wontfix)"
修改系统提示词,增加输出字段说明:
py
# 加系统提示词,并且添加 JSON 提示
# system_prompt="All result must be a json object"
# 提示词定义结构化输出字段
system_prompt = """
All result must be a json object
{
"answer": "The answer to the question",
"question": "The question asked"
}
"""
可以对比输出,输出的结构化字段固定了。经验法则:字段名说不清楚的地方,加说明;吃不准,加一个示例(Few-shot,成本最低)。
更进一步的 Structured Output(结构化输出) 可以精确约束字段和类型------类比前端这层就是 LLM 的 TypeScript 类型定义。
安装pydantic,API调用时需要使用client.chat.completions.parse();解析结果是需要使用.parsed:
sh
pip install pydantic
定义结构化数据模型:
py
from pydantic import BaseModel
class Result(BaseModel):
answer: str
question: str
def call_model(messages):
response = client.chat.completions.parse(
model="deepseek-v4-pro",
messages=messages,
reasoning_effort="high",
response_format=Result,
extra_body={"thinking": {"type": "enabled"}}
)
return response.choices[0].message.parsed
目前国内模型基本都不支持该模式,只有火山方舟的模型支持。但是还在beta中,不稳定。常用的提示词输出结构就是第二种方式了,定义输出结构字段,并说明输出字段表示什么。
通用问答按照输出展示就行。但是需要对数据进行二次分析汇总,我觉得就需要结构化输出,可预知。程序里可以直接 result.answer,跟调本地函数一样,不需要从自由文本里抠字段。
Prompt 模板化与版本管理
Prompt 和前端代码一样需要版本管理和 A/B 测试。不是"写一次就完了"。
模板化:
py
# 不好的做法:每次拼接字符串
prompt = f"你是一个{role},请分析以下文本:{text},按照{format}格式输出"
# 好的做法:用模板引擎
from string import Template
# 提示词模板
prompt_template = Template("""
你是一个 $role,擅长 $skills。
任务:$task
输入:
$input
输出格式要求:
$output_format
""")
# 不同场景传入不同参数
code_review_prompt = prompt_template.substitute(
role="Python 代码审查专家",
skills="性能优化、安全审计、代码规范",
task="审查以下代码,输出问题列表和改进建议",
input=user_code,
output_format="JSON,字段:severity, line, issue, suggestion",
)
为什么使用Template而不用f-string?Template可以提前定义,使用时再定义参数。而f-string是在执行时的字符串拼接。
版本管理(存 Git + 数据库):
text
prompts/
├── code_review/
│ ├── v1.0.md # 基础版
│ ├── v1.1.md # 加了安全检查维度
│ └── v2.0.md # 改用 Structured Output 格式
├── translation/
│ └── v1.0.md
跟前端 feature flag 一样管理 Prompt 变体。不需要装包,就几个函数自己写:
py
import os
# 版本配置(改这里就能切版本)
PROMPT_VERSIONS = {
"code_review": "v2.0", # 90% 用户走 v1.1,10% 走 v2.0
"translation": "v1.0",
}
def get_prompt_version(feature_name, user_id=None):
"""根据用户 ID 决定走哪个 Prompt 版本(A/B 测试)"""
version = PROMPT_VERSIONS.get(feature_name, "v1.0")
# 10% 用户走新版,90% 走旧版
if user_id and feature_name == "code_review":
if hash(user_id) % 10 == 0:
return "v2.0"
return version
def load_prompt(path):
"""读取 Prompt 文件"""
with open(f"prompts/{path}") as f:
return f.read()
# 使用
prompt = load_prompt(f"code_review/{get_prompt_version('code_review', request.user_id)}.md")
这里可以自定义版本加载逻辑,根据实际业务规则。
Token 与上下文窗口
LLM 不是按"汉字"或"单词"来处理文本的,是按 Token。不同模型的分词器不同,同一个文本切出来的 Token 数可能差不少。
字符和 Token 的参考换算(不同模型有差异):
text
1 个英文字符 ≈ 0.3 token → 1000 英文 ≈ 300 token
1 个中文字符 ≈ 0.6 token → 1000 中文 ≈ 600 token
中文的 Token 消耗大约是英文的两倍
精确值以 API 返回的 response.usage.prompt_tokens 为准。不同模型实际 Token 数会有偏差。
计费按 Token 算------输入和输出都计费,输出通常比输入贵 3-4 倍。不同模型价格不同,查最新 API 文档。
使用tiktoken来估算费用, tiktoken是 OpenAI 的 Token 计数器,写代码前先学会数 Token,才能控制成本。
安装:
sh
pip install tiktoken
py
import tiktoken
# 用通用编码器(大多数模型兼容此格式)
enc = tiktoken.get_encoding("cl100k_base")
text = "你好世界"
tokens = enc.encode(text)
print(len(tokens))
上下文窗口
模型一次能"看到"的 token 上限就叫上下文窗口。
text
发展历程:
早期模型 → 1K ~ 4K tokens 几段话到几页纸
现在主流 → 128K ~ 200K tokens 一本到两本书的体量
最新模型 → 1M tokens 起步 可以一次塞进整个代码仓库
为什么重要? 窗口越大,一次能喂进去的上下文越多------整个项目代码、长文档、多轮对话历史。超过窗口就会被截断,模型"忘记"之前的内容。
text
对话进行中:
第 1 轮:1K tokens
第 5 轮:5K tokens
第 20 轮:20K tokens ← 早期对话可能被挤出窗口
所以做长对话应用时,超过窗口需要做摘要 或滑动窗口来管理上下文。
Streaming 流式响应
LLM 生成文本很慢------一个几百字的回答可能要好几秒。如果不做流式,用户盯着空白等死。
text
非流式(同步):
前端发请求 → 服务器等 → 模型一个字一个字生成完 → 拼成完整回复 → 一次性返回
用户体验:点击发送 → 白屏 5 秒 → 突然冒出全文
流式(Streaming):
前端发请求 → 服务器每生成几个字就推给前端 → 前端逐个字渲染
用户体验:点击发送 → 文字一个一个往外蹦,像打字
对前端来说,流式就是 Server-Sent Events(SSE) ,跟 WebSocket 类似但更简单------服务器单向推送,浏览器自动重连。
修改参数stream=True,使大模型流式结果响应:
py
def main():
call_model([{"role": "user", "content": "你是谁,详细介绍?"}])
# 调用大模型
def call_model(messages):
# 加系统提示词,并且添加 JSON 提示
system_prompt="All result must be a json object"
messages.insert(0, {"role": "system", "content": system_prompt})
response = client.chat.completions.create(
model="deepseek-v4-pro",
messages=messages,
stream=True,
reasoning_effort="high",
response_format={"type": "json_object"},
extra_body={"thinking": {"type": "enabled"}}
)
result = ""
for chunk in response:
content = chunk.choices[0].delta.content
if content:
# 打字机效果
print(content,end="", flush=True)
result += content
return result
执行后可以看到终端类似打字机输出响应结果。
实现前后端流式响应
创建调用API接口,后端使用FastAPI创建服务和sse_starlette创建sse响应:
安装:
sh
pip install fastapi sse_starlette uvicorn
接口服务部分代码,要启动服务,需要安装uvicorn,然后通过uvicorn main:app --reload启动开发服务:
py
from fastapi import FastAPI
from sse_starlette.sse import EventSourceResponse
app = FastAPI()
class ChatRequest(BaseModel):
messages: list[dict]
@app.post("/chat")
async def chat(req: ChatRequest):
return EventSourceResponse(call_model(req.messages))
修改大模型调用函数call_model,函数体必须为异步函数,返回一个迭代器。
py
# 调用大模型
async def call_model(messages):
# 加系统提示词,并且添加 JSON 提示
system_prompt="All result must be a json object"
messages.insert(0, {"role": "system", "content": system_prompt})
response = client.chat.completions.create(
model="deepseek-v4-pro",
messages=messages,
stream=True,
reasoning_effort="high",
response_format={"type": "json_object"},
extra_body={"thinking": {"type": "enabled"}}
)
for chunk in response:
content = chunk.choices[0].delta.content
if content:
yield {"data": content}
前端部分通过fetch请求获取sse响应
js
const response = await fetch("http://127.0.0.1:8000/chat", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
messages: [{ role: "user", content: "解释一下机器学习" }],
}),
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// SSE 用 \r\n\r\n 分隔每条消息
const parts = buffer.split("\r\n\r\n");
buffer = parts.pop();
for (const part of parts) {
const line = part.trim();
if (!line.startsWith("data: ")) continue;
const text = line.slice(6).trim();
if (text) {
document.getElementById("output").textContent += text;
}
}
}
httpx 读取流式响应
httpx 是一个 Python 的 HTTP 客户端,可以用来发送 HTTP 请求,并自动处理响应。
安装:
sh
pip install httpx
也可以不用OpenAI SDK,直接通过httpx转发调用大模型API接口。
py
import httpx
async def stream_api(messages):
# 加系统提示词,并且添加 JSON 提示
system_prompt = """
All result must be a json object
{
"answer": "The answer to the question",
"question": "The question asked"
}
"""
messages.insert(0, {"role": "system", "content": system_prompt})
async with httpx.AsyncClient() as client:
async with client.stream(
"POST",
"https://api.deepseek.com/chat/completions",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={
"model": "deepseek-v4-pro",
"messages": messages,
"stream": True,
"thinking": {"type": "enabled"},
"reasoning_effort": "high",
},
) as response:
async for line in response.aiter_lines():
if line.startswith("data: "):
chunk = line[6:] # 去掉 "data: " 前缀
if chunk != "[DONE]":
yield chunk
@app.post("/chat")
async def chat(req: ChatRequest):
# return EventSourceResponse(call_model(req.messages))
return EventSourceResponse(stream_api(req.messages))
两种方式的区别:
| OpenAI SDK 流式 | httpx 直接请求 | |
|---|---|---|
| 适用场景 | 只调 OpenAI 协议支持的 | 调任何 LLM API(自部署、Claude、DeepSeek) |
| 复杂度 | 低,自动处理 chunk | 中,手动解析 SSE 协议 |
| 灵活性 | 低 | 高,完全控制请求/响应 |
做 LLM 应用,流式不是优化项,是基本功能。
模型选型
三类选择
text
云端闭源(付钱省心) → OpenAI / Anthropic / 国内大厂
云端开源(性价比) → DeepSeek 等
本地部署(数据不出域)→ Ollama / llama.cpp
选型原则不是"哪个模型最好",是"你的场景需要什么"。
-
看任务复杂度
- 简单任务(分类、提取、翻译)→ 便宜模型足够,没必要上最贵的
- 复杂推理(代码生成、数学、长文档分析)→ 用能力最强的
-
看上下文窗口
- 单轮问答几 KB → 随便选
- 分析整个项目代码/长文档 → 需要大窗口(128K 起步)
-
看数据安全
- 非敏感数据 → 调 API,省运维
- 敏感数据/内部代码 → 本地部署,数据不出域
-
看成本
- 每天几千次调用 → 选便宜的,便宜模型做简单任务
- 偶尔用 → 选最强的,贵一点但省人工调试
-
看延迟
- 实时对话 → 小模型/流式输出
- 后台批处理 → 大模型慢慢跑
本地部署
不想把数据发云端,就本地跑。两款工具:
Ollama --- 极简、易用
sh
brew install ollama
ollama pull qwen2.5:1.7b # 选当前最新的轻量模型
ollama run qwen2.5:1.7b # 终端直接对话
- 好处:离线可用、数据不出域、免费、接口兼容 OpenAI 格式
- 限制:本地模型能力远不如云端,适合原型开发和敏感数据处理
llama.cpp --- 极致控制
Ollama 底层用的就是 llama.cpp。需要自定义量化、精确控制显存时用。
sh
brew install llama.cpp
llama-cli --model model.gguf --prompt "你好" --temp 0.7 --n-predict 200
text
Ollama ≈ 用 Vercel 部署 (封装好,一键启动)
llama.cpp ≈ 自己配 Nginx (裸机操作,精细控制)
日常学习用 Ollama 就够了。