【项目链接 】https://github.com/AkagawaTsurunaki/zerolan-core
在上一篇中,我们把 ZerolanCore 的"骨架"搭好了------怎么管依赖、怎么定接口规范。今天,咱们要往这个骨架里塞入真正的核心:大语言模型(LLM)。
老实说,如果你一直写 Go,习惯了那种"强类型约束、编译期报错"的安全感,初看 Python 搞 AI 可能会觉得有点玄学:"这字典怎么就突然变成对象了?"、"这个模型是怎么一边算一边往外蹦字的?"
别急,咱们今天就剥开这层玄学的外衣,看看 Python 是怎么用极其轻量的语法,干出和 Go 一样优雅的工程架构的。
本篇咱们要扒的源码:
- 配置是怎么"吃"进去的:[llm/xxx/config.py]
- 模型是怎么"动"起来的:[llm/xxx/model.py]
- 流式响应是怎么"流"出来的 :[common/abs_model.py]里的
yield魔法 - Web 端怎么接数据:[llm/app.py]
1. 配置映射:Python 里的"自动 Unmarshal"
咱们写 Go 的时候,解析配置文件(比如 YAML)通常得怎么干?建个 struct,认认真真打上 yaml:"model_path" 的标签,然后调 Unmarshal,对吧?
但在 Python 里,ZerolanCore 玩了一种更"偷懒"但极其好用的招数。模型类(Model)自己才不想管配置文件是存在 C 盘还是 D 盘,也不想去调什么 yaml.load。它的工作是算力,不是打杂。谁把它拉起来的(比如外层的启动器),谁就得把配置剥好了喂给它。
上面这就是我们常说的依赖注入 (DI)。
而在代码里,我们是怎么把字典变成对象的呢?靠的是 @dataclass 装饰器和 ** 关键字解包(Kwargs Unpacking)。
来看看 Go 和 Python 在处理配置映射时的画风对比:
| 特性 | Go 语言 (struct + Unmarshal) |
Python (@dataclass + **解包) |
|---|---|---|
| 定义方式 | type Config struct { Path string \yaml:"path"` }` |
@dataclass class Config: path: str |
| 映射动作 | yaml.Unmarshal(data, &config) |
Config(**yaml_dict) |
| 构造函数 | 需手动编写 NewConfig(path string) |
@dataclass 自动生成 __init__ |
| 灵活性 | 编译期强校验,安全但繁琐 | 运行时动态解包,所见即所得,极度灵活 |
实战代码演示:
假设启动器读到了一个字典 dict = {"path": "/models/qwen", "max_len": 2048}。
python
from dataclasses import dataclass
@dataclass
class QwenConfig:
path: str
max_len: int = 1024 # 还能自带默认值
# 魔法解包:一个 ** 就把字典"炸开"填进去了
yaml_dict = {"path": "/models/qwen", "max_len": 2048}
config = QwenConfig(**yaml_dict)
print(config.path) # 输出: /models/qwen
以后写单元测试,你甚至不用去硬盘上建个假的 yaml 文件,直接 model = Qwen(QwenConfig(path="test")) 就能跑,代码干净得让人神清气爽。
2. 揭秘底层:Tokenizer 与 Model 的"双子星"配合
好,配置吃进去了,接下来模型该怎么工作?
你丢给模型一句『你好』,它其实是一脸懵的。因为它的大脑里全都是矩阵、张量、浮点数。一个搞数学的,哪懂你们人类的文字啊?
所以在 Hugging Face 的 transformers 库里,大模型的干活流程永远是这三板斧:
- Tokenizer (分词器/翻译官) :它的工作就是序列化 。把人类的
"你好"查字典,翻译成一串 ID[278, 331]。这就相当于 Go 里的json.Marshal,把结构体变成网络能认识的[]byte。 - Model (真正的计算大脑) :包含几十 GB 权重的神经网络。它吞下
[278, 331],开始疯狂的矩阵乘法,最后预测出下一个词的 ID 是[1093]。 - Tokenizer (解码器) :再次上场,进行反序列化 ,把
[1093]变回人类能看懂的"啊"。
在代码里,我们用了 AutoTokenizer 和 AutoModelForCausalLM 这两个工厂方法:
python
from transformers import AutoTokenizer, AutoModelForCausalLM
# 1. 翻译官上场:加载词表
tokenizer = AutoTokenizer.from_pretrained("/models/qwen")
# 2. 大脑上场:把几十GB的模型塞进 GPU
model = AutoModelForCausalLM.from_pretrained("/models/qwen", device_map="auto")
# 3. 序列化 -> 算力推理 -> 反序列化
inputs = tokenizer("你好", return_tensors="pt")
outputs = model.generate(**inputs)
text = tokenizer.decode(outputs[0])
你不用管底下是 Qwen 还是 DeepSeek,工厂会自动看它的身份证(config.json)来实例化。这不就是咱们最爱的多态和面向接口编程嘛!
3. 让 AI 像人一样说话:yield 的流式魔法
如果你用过 ChatGPT,一定对那种"打字机"效果印象深刻。为什么不让它算完了再一起发给我们?
因为大哥算力有限啊。你问一个长篇大论,它得在 GPU 里算 1000 次。要是算完了再给你,你看着白屏不得等急眼了?所以大模型的自回归 (Auto-regressive) 特性决定了:它算出一个字,就得先扔给你一个字,你先看着,它接着算。
那在代码里怎么实现这种"边生产、边消费"呢?
如果用 Go 写,你肯定会开个 Goroutine,算出一个字,就往 chan string 里塞,另一头 for range 不断地拿。但在单线程的 Python 里,有一个神仙关键字叫 yield。
| 语言 | 异步数据流机制 | 特点 |
|---|---|---|
| Go | Goroutine + Channel |
并发度高,但需处理锁、通道关闭等复杂逻辑 |
| Python | yield (生成器 Generator) |
单线程内挂起与恢复,代码极简,无锁竞争 |
来看看这魔法是怎么运作的:
python
# 模型层的推理逻辑
def stream_predict(self, query):
for response in self._model.stream_chat(query):
# 算出一个字,就按暂停键(冻结),把字扔出去!
yield self._to_pipeline_format(response)
当函数跑到 yield 的时候,函数就像被按了暂停键 ,把结果扔给外面。外面的 Flask 拿到这个字推给前端后,再回来按播放键(唤醒),函数接着算下一个字。没有复杂的互斥锁,一个单线程的生成器就把异步数据流玩得明明白白。
4. Web 层的最后一公里:NDJSON 协议
模型把数据 yield 出来了,但别忘了,我们还得通过 HTTP 把它送到前端浏览器去。
打开 [llm/app.py],你会看到这样一句奇怪的代码:
python
# Web 层的消费逻辑
def generate_output(q):
for p in self.model.stream_predict(q):
# 为什么要加 '\n' 换行符?
yield p.model_dump_json() + '\n'
前端兄弟可能会抱怨:"你一直给我发 JSON,我 JSON.parse 直接崩了啊!你发的是 {"res":"你"}{"res":"好"},这连个数组都不是,格式全乱了!"
我们说:"行,那我在每个 JSON 后面强行加个换行符 \n。你拿到了自己按行劈开(Split),只取最后一行去解析,总行了吧?"
这就是业界常用的 NDJSON (Newline Delimited JSON) 协议。结合 Flask 的 stream_with_context,HTTP 协议底层的 Transfer-Encoding: chunked 会把这些带有换行符的独立 JSON 字符串,像源源不断的流水一样推给前端。
text
// 前端收到的数据长这样:
{"response": "你"}
{"response": "你好"}
{"response": "你好啊"}
前端拿到后,渲染出来的就是丝滑的打字机效果。
💡 课后深度思考:通向"多模态"的门票
好啦,到这里,我们的 AI 已经有了一个能流式思考的"大脑"。但是,如果你想做一个像豆包那样可以倾听你的语音并用特定音色回复你的实时语音助手,事情就没这么简单了。
作为后端的你,不妨先在脑子里盘算一下这几个棘手的问题:
- 关于"听" (ASR 语音识别):用户对着麦克风一直叭叭叭,你是等他说完再发给后端,还是切成 500 毫秒的小碎片发过去?如果是发碎片,后端模型怎么知道上一句的"你"和下一句的"好"是连着的?
- 关于"说" (TTS 语音合成) :刚才说了,LLM 是一个字一个字蹦出来的。如果每蹦出一个字,你就去调一次声音合成,那出来的声音绝对是"你------好------啊",像没感情的机器人。你该怎么"憋"着这些字,在什么时机触发合成,才能让声音既有感情,又不会让用户等太久?
- 关于"架构灾难" :像 GPT-SoVITS 这种极其吃环境、依赖包多如牛毛的语音合成模型,如果你像今天集成 LLM 一样,直接把它
import进主进程,你的项目大概率会原地爆炸。那该怎么隔离它?
在下一篇:多模态 Pipeline 深度解析中,我们将带着这三个灵魂拷问,看看 ZerolanCore 是如何像接水管一样,把 ASR(听)、LLM(想)和 TTS(说)优雅且稳健地串联起来的!