从 Token 到 API 调用: LLM 实战笔记

最近在做一个基于《论语》的孔子 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 都是为"采样"服务的:前者调概率分布形状,后者砍候选集

  • 用户抱怨"太死板 + 太离谱"同时出现时,需要两个参数配合调,而不是只调一个

相关推荐
qeen875 小时前
【算法笔记】各种常见排序算法详细解析(下)
c语言·数据结构·c++·笔记·学习·算法·排序算法
Yeh2020586 小时前
springboot+vue笔记
vue.js·spring boot·笔记
不动明王呀7 小时前
almalinux8.10配置免密登录笔记
笔记
问心无愧05137 小时前
ctf show web 入门152
前端·笔记
05候补工程师7 小时前
【408狂飙·数据结构】核心考点深度复盘:数组地址计算、特殊矩阵压缩存储与树的五大性质解题直觉
数据结构·笔记·线性代数·考研·算法·矩阵
小+不通文墨7 小时前
在树莓派中部署emqx
经验分享·笔记·单片机·学习
Fu2067217 小时前
OSPF笔记 OSPF --- 开放式最短路径优先
网络·笔记
William Dawson7 小时前
【软考中级备考日记|系统集成项目管理工程师Day20:终章上岸|最后一页纸必考清单(考场直接默写、零基础必背)】
笔记·系统集成项目管理工程师
玄米乌龙茶1238 小时前
LLM 应用开发学习笔记:System Prompt 设计、注入风险与成本优化
笔记·学习·prompt