最近在做一个基于《论语》的孔子 AI 问答项目,踩了不少坑,也学到了很多 LLM 应用开发的底层知识。这篇文章是我整理的实战笔记,涵盖 Token 原理、Chat Completion 的工作方式、一次完整 API 调用的全链路,以及 Temperature 和 Top-P 的调优心得。希望能帮助同样在探索 LLM 应用的朋友。
1. Token:LLM 世界里的最小意义单元
什么是 Token?
Token 是 LLM 能够处理的最小意义单元。每个 Token 都有一个数字编号(Token ID)。Tokenizer(分词器)按照词语的使用频率来分配 Token:高频词/字拥有独立编号(1 个 Token),生僻字没有独立编号,会被拆分成偏旁或部件(多个 Token)。
中文没有空格分隔,Tokenizer 依靠统计概率来判断切分位置。相同语义下,中文通常比英文消耗更多 Token,因为生僻字可能被拆成多个 Token。
一个形象的比喻
-
高频词 = 大明星,有独立电话号码(1 Token)
-
低频/生僻词 = 跑龙套,得通过认识的偏旁+部件一个一个拼起来(多个 Token)
举例:
-
"醢"(生僻字)→ 酉 + 右 + 皿 → 3 Tokens
-
"学而时习之"(常用字)→ 5 Tokens(每个字都有独立编号)
实践参数
-
max_tokens:控制回复的最大长度。1024 足够孔子说 3-5 句文言文。 -
temperature:控制创造性。0 最死板,2 最天马行空。角色扮演场景 1.1 刚好------偶尔机灵但守规矩。
成本估算
以 DeepSeek API 为例:
-
Input: ¥1 / 百万 Token
-
Output: ¥2 / 百万 Token
本项目单次对话约 2850 input tokens + 200 output tokens ≈ ¥0.006(不到 1 分钱)。
2. Chat Completion 原理:每次都是"新客服"
核心概念
LLM 没有内部记忆。每次请求都需要把完整的对话历史(system + 全部 user + 全部 assistant)重新传一遍。
三种角色:
-
system:最开头,设定规则
-
user:用户发言
-
assistant:模型之前的回复,构成历史
对话成本随轮数线性增长:10 轮 vs 1 轮,input token 差 10 倍。
形象的比喻
每次和 LLM 对话,就像打电话换了一个新客服:
-
新客服能看你之前的聊天记录(上下文)
-
但本质上和你不认识,因为"脑子"是全新的
-
每次回答完就忘了你,下次请求又是一个全新客服
上下文窗口管理
项目中设置 MAX_HISTORY_MESSAGES = 20(即 10 轮对话)。超过部分不传给 LLM,但数据库不会删除------翻历史时仍能看到完整记录。
另外注意 "Lost in the Middle" 现象:LLM 对开头和结尾的信息最敏感,中间部分容易被忽略。
实践调整
将 MAX_HISTORY_MESSAGES 从 20 改成 12(降到 6 轮),可以有效减少 Token 消耗。
3. API 调用全链路:一条消息的 10 步旅程
一条简单的消息"什么是仁?"从前端发出到收到回复,到底经过了哪些层?每一步做了什么?
完整链路
① PreflightMiddleware --- OPTIONS 预检
浏览器在发送跨域 POST 请求之前,会自动先发一个 OPTIONS 请求去"探路"------问服务器"我能不能发 POST 给你?"
PreflightMiddleware 遇到 OPTIONS 直接返回 200,告诉浏览器"行,来吧"。
② CORSMiddleware --- 跨域校验
OPTIONS 通过后,服务器在响应头中加上具体的跨域权限:
text
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: POST, GET, DELETE
Access-Control-Allow-Headers: Authorization
浏览器收到这些头,对比自己的请求是否符合规则------这就是"虽然都是本地,但端口不同就不能访问"的背后机制。
③ 路由匹配 + Pydantic 校验
FastAPI 根据 /api/chat 和 POST 方法找到对应的 chat() 函数。Pydantic 自动校验请求体 JSON:
json
// 前端发来
{"message":"什么是仁?","conversation_id":3}
// Pydantic 校验:
// message: str ✓,不能为空
// conversation_id: int | None ✓,可以不传
字段名或类型错误会直接返回 422,业务代码拿到的数据一定是合法的。
④ JWT 鉴权
从请求头 Authorization: Bearer eyJ... 中取 Token → 用密钥解密 → 拿到 user_id → 查数据库确认用户存在。任何一步失败都返回 401。
⑤ 查历史消息
如果是已有对话(传了 conversation_id),从数据库取该对话最近 20 条消息(即 10 轮),按时间正序排列。如果是新对话,创建一个新 Conversation 行(标题取首条消息前 30 字),历史为空。
⑥ 意图分类
将当前用户消息发给 LLM 做二分类(闲聊 or 求教)。注意:只传当前这一条消息,不传历史。因为分类 Prompt 里已经包含了 Few-shot 示例,足以教会 LLM 判断。
⑦ RAG 检索(仅求教模式)
将用户问题用 BGE 模型转成 1024 维向量 → 在 ChromaDB 的 analects 集合中找最相似的 5 条论语句子 → 返回原文 + 篇名 + 序号。
⑧ 拼 Prompt → 调 DeepSeek API
组装最终发给 LLM 的消息列表:
text
[system: "你是孔子..."]
[user: "什么是仁?"] ← 历史消息
[assistant: "仁者爱人..."] ← 历史消息
[user: "参考知识: ... 用户问题: 举个例子"] ← 当前消息 + RAG 上下文
通过 OpenAI SDK 发送 POST 请求到 https://api.deepseek.com,等待回复。
⑨ 存消息到数据库
收到 LLM 回复后,将一条 user 消息和一条 assistant 消息存入数据库,同时更新 conversation 的 updated_at 时间戳。
⑩ 返回 JSON 给前端
json
{
"reply": "仁者,爱人也...",
"sources": [{"chapter":"雍也篇", "text":"...", "score":0.56}],
"intent": "求教",
"conversation_id": 3
}
前端拿到数据后更新消息列表、渲染新气泡。如果是流式请求,则逐 Token 推送 SSE 事件,前端逐字追加。
conversation_id 的作用
-
新建对话:前端不传 → 后端建 Conversation → 返回
conversation_id -
继续对话:前端传
conversation_id→ 后端查messages WHERE conversation_id = ?→ 拼出历史
同一个用户可以有多段独立对话(关于仁的、关于学习的、闲聊的),互不干扰。
4. Temperature & Top-P:控制模型的"性格"
核心概念
-
Temperature:在 softmax 之前对 logits 做除法(softmax(logits / T)),调节概率分布的"陡峭程度"
-
T → 0:差异被放大,概率最高的词几乎 100%,模型保守、确定
-
T = 1:保留原始概率分布,自然
-
T > 1.5:概率被抹平,低概率词也有机会被选中,模型变得随机、敢冒险
-
-
Top-P(核采样):不缩放概率,而是砍掉长尾低概率词。按概率从高到低累加,到累计 ≥ p 就停,圈内的候选词参与采样,圈外的全部丢弃。
形象的比喻
-
Temperature = 地形改造器:T → 0 把概率分布变成喜马拉雅山(一峰独立);T > 1.5 变成丘陵(大家都差不多)
-
Top-P = 入围门槛:选班干部时只让票数最高的前几个人入围,后面的人连候选资格都没有
-
加权随机采样:不是选概率最高的,而是按概率比例扔有偏见的骰子。T=0 时骰子所有面都是同一个词(退化成贪心选择)
底层机制
模型每生成一个 Token,是对词汇表(5万+ 词)全部打一次分(logits),然后 softmax → 概率 → temperature/top_p → 扔骰子。这个打分是一次并行矩阵乘法,不是逐个词试的。
生僻字被拆成多 Token,是因为 Tokenizer 训练时词汇表里根本没有那个字------模型只能用积木块拼。
项目中的三种温度设置
| 场景 | Temperature | 原因 |
|---|---|---|
| 意图分类 | 0.0 | 输出只有 2 个选项,必须一致稳定 |
| RAG 回答 | 0.8 | 有原文约束但需要理解释译,不能死抄也不能瞎编 |
| 闲聊 | 1.1 | 无原文约束,保持角色灵活性 |
关键认知
-
Temperature 和 Top-P 都是为"采样"服务的:前者调概率分布形状,后者砍候选集
-
用户抱怨"太死板 + 太离谱"同时出现时,需要两个参数配合调,而不是只调一个