【AI】基于 Qwen2.5 模型进行 LoRA 微调

【AI】基于 Qwen2.5 模型进行 LoRA 微调

本文介绍了Lora训练的相关知识和实操记录

前言:什么是 LoRA?为什么选它?

LoRA(Low-Rank Adaptation) 是一种参数高效微调(PEFT)技术。想象你有一个预训练好的大模型(如 Qwen2.5),它已经在海量数据上学会了语言规律。现在你想让它学会特定任务(如数学题推理),但有两个问题:

  1. 算力问题:全参数微调需要巨大的 GPU 内存和训练时间
  2. 存储问题:每次微调都保存一个完整模型(3GB+),成本高昂

LoRA 的解决方案:冻结原模型参数,只训练少量”低秩矩阵”(Adapter)。这就像给手机装插件,而不是重写整个操作系统。

本文目标:在 Apple Silicon(M1/M2/M3)Mac 上,使用 MLX 框架对 Qwen2.5-1.5B 进行 LoRA 微调,让模型学会”分步思考”的推理能力。


第一章:环境准备与工具链搭建

1.1 为什么选择 MLX?

MLX 是 Apple 专为自家芯片设计的机器学习框架,相比 PyTorch 有以下优势:

特性MLXPyTorch (MPS)
内存效率⭐⭐⭐ 极高,统一内存架构优化⭐⭐ 较好,但显存管理较粗
训练速度⭐⭐⭐ 针对 Metal 深度优化⭐⭐ 通用实现,非 Apple 最优
量化支持⭐⭐⭐ 原生支持 4-bit/8-bit⭐⭐ 需额外配置
生态成熟度⭐⭐ 较新,文档在完善中⭐⭐⭐ 成熟,社区庞大

决策建议:如果你主要在 Mac 上做训练/推理,MLX 是首选;如果需要跨平台部署,PyTorch 更通用。

1.2 安装 Miniconda(环境隔离)

# 下载 ARM64 版本(适配 Apple Silicon)
curl -O https://repo.anaconda.com/miniconda/Miniconda3-latest-MacOSX-arm64.sh

# 执行安装脚本
bash Miniconda3-latest-MacOSX-arm64.sh

# 安装完成后,重启终端或执行以下命令使配置生效
source ~/.zshrc  # 或 ~/.bashrc

⚠️ 注意事项

  • 安装过程中会询问是否初始化 conda,建议选 yes(自动配置 PATH)
  • 如果之前安装过 Anaconda,建议先完全卸载,避免环境冲突
  • 安装路径建议保持默认(~/miniconda3),避免权限问题

1.3 创建隔离的 Python 环境

# 创建 Python 3.11 环境(MLX 对 3.9-3.11 支持最好)
conda create -n mlx-train python=3.11 -y

# 激活环境
conda activate mlx-train

# 验证 Python 版本
python --version  # 应显示 Python 3.11.x

为什么用 3.11 而非最新版? MLX 和许多 ML 库对 Python 版本敏感,3.11 是目前稳定性与性能的最佳平衡点。

1.4 安装核心依赖包

# 核心:MLX 语言模型工具包
pip install mlx-lm

# 辅助工具(用于后续验证和格式转换)
pip install torch transformers accelerate safetensors

# 分词器依赖(Qwen 系列必需)
pip install sentencepiece protobuf tiktoken

# 数据处理
pip install datasets  # HuggingFace 数据集工具
pip install jsonlines  # 高效处理 JSONL 文件

依赖冲突排查

# 如果安装后出现问题,检查版本兼容性
pip list | grep -E "(mlx|torch|transformers)"

第二章:模型获取与本地部署

2.1 安装 HuggingFace CLI 工具

# 使用 Homebrew 安装(推荐)
brew install huggingface-cli

# 验证安装
hf --version

重要变更提醒:旧版命令 huggingface-cli 已简化为 hf,但部分文档可能仍使用旧命令,注意区分。

2.2 配置 HuggingFace 访问权限

# 登录(需要 HuggingFace 账号和 Access Token)
hf login

# 或者设置环境变量(自动化脚本推荐)
export HF_TOKEN="your_token_here"

获取 Token 步骤

  1. 访问 https://huggingface.co/settings/tokens
  2. 创建 New Token(选择 read 权限即可下载模型)
  3. 复制并妥善保存(Token 只显示一次)

2.3 下载 Qwen2.5 模型

# 创建模型存储目录
mkdir -p ./models

# 下载 1.5B 指令版(适合 Mac 本地训练)
hf download Qwen/Qwen2.5-1.5B-Instruct --local-dir ./models/Qwen2.5-1.5B-Instruct

模型选择指南

模型版本参数量磁盘占用内存需求适用场景
Qwen2.5-0.5B5亿~1GB4GB+快速测试、边缘设备
Qwen2.5-1.5B15亿~3GB8GB+Mac 本地训练推荐
Qwen2.5-7B70亿~14GB24GB+高性能 Mac/云端
Qwen2.5-14B140亿~28GB48GB+专业工作站

下载优化技巧

# 如果下载中断,使用 resume 继续
hf download Qwen/Qwen2.5-1.5B-Instruct --local-dir ./models --resume-download

# 仅下载特定文件(如只需要 safetensors)
hf download Qwen/Qwen2.5-1.5B-Instruct --include "*.safetensors" "*.json"

2.4 模型完整性验证

下载完成后,验证文件结构:

ls -lh ./models/Qwen2.5-1.5B-Instruct/
# 应包含:config.json, model.safetensors, tokenizer.json 等

第三章:模型推理能力基线测试

3.1 编写验证脚本

在正式训练前,必须测试基础模型是否能正常运行,这将成为后续对比的基线。

# verify_base_model.py
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
import time

def test_model():
    # 配置路径(根据实际下载路径调整)
    model_path = "./models/Qwen2.5-1.5B-Instruct"
    
    print("🔄 正在加载分词器...")
    tokenizer = AutoTokenizer.from_pretrained(
        model_path, 
        trust_remote_code=True,
        padding_side="left"  # 生成任务建议左填充
    )
    print(f"✅ 分词器加载成功 | 词表大小: {len(tokenizer)}")
    
    print("\n🔄 正在加载模型(约 3GB,请耐心等待)...")
    start_time = time.time()
    
    model = AutoModelForCausalLM.from_pretrained(
        model_path,
        torch_dtype=torch.float16,      # 半精度节省内存
        device_map={"": "mps"},         # 强制使用 Mac GPU
        trust_remote_code=True,
        low_cpu_mem_usage=True          # 优化加载内存峰值
    )
    
    load_time = time.time() - start_time
    print(f"✅ 模型加载成功 | 耗时: {load_time:.1f}s | 设备: {model.device}")
    
    # 测试用例:观察基础模型的推理能力
    test_cases = [
        "你好,请介绍一下你自己。",
        "1+1等于几?请详细解释。",
        "有3只兔子,每只4条腿,一共有多少条腿?请分步思考。"
    ]
    
    print("\n" + "="*50)
    print("🧪 开始推理测试")
    print("="*50)
    
    for i, prompt in enumerate(test_cases, 1):
        print(f"\n--- 测试 {i} ---")
        print(f"输入: {prompt}")
        
        # 构建对话格式(Qwen  chat template)
        messages = [{"role": "user", "content": prompt}]
        text = tokenizer.apply_chat_template(
            messages,
            tokenize=False,
            add_generation_prompt=True
        )
        
        inputs = tokenizer(text, return_tensors="pt").to("mps")
        
        # 生成参数调优
        with torch.no_grad():
            outputs = model.generate(
                **inputs,
                max_new_tokens=200,
                temperature=0.7,        # 控制创造性
                top_p=0.9,              # 核采样
                repetition_penalty=1.1,  # 抑制重复
                do_sample=True
            )
        
        response = tokenizer.decode(outputs[0], skip_special_tokens=True)
        # 提取助手回复部分
        if "assistant" in response:
            response = response.split("assistant")[-1].strip()
        
        print(f"输出: {response[:200]}...")
        print(f"生成长度: {len(outputs[0])} tokens")

if __name__ == "__main__":
    test_model()

3.2 关键参数解析

参数作用建议值
torch_dtype权重精度float16(Mac 推荐)或 bfloat16
device_map设备分配{"": "mps"} 强制 GPU,"auto" 自动分配
low_cpu_mem_usage分片加载True(大模型必需)
temperature采样随机性0.1-0.3(确定性任务),0.7-0.9(创造性)

⚠️ 常见问题

  • MPS 后端报错:如果遇到 MPS backend out of memory,尝试重启 Python 进程释放显存
  • 结果不一致:MPS 的确定性不如 CUDA,相同输入可能有细微差异,属正常现象

第四章:训练数据准备与格式化

4.1 理解数据格式要求

MLX LoRA 训练要求数据为 JSON Lines 格式(.jsonl),每行一个 JSON 对象,必须包含 text 字段:

{"text": "User: 问题\n\nAssistant: <|thought|>\n思考过程\n<|solution|>\n最终答案"}

4.2 原始数据结构分析

你的原始数据包含以下关键字段:

  • problem: 问题描述
  • thinking: 详细思考过程(CoT - Chain of Thought)
  • solution: 结构化答案

训练目标:让模型学会在看到问题时,先输出 <|thought|> 标记,然后展示思考过程,最后给出答案。

4.3 数据转换脚本(增强版)

# convert_dataset.py
import json
import random
import argparse
from pathlib import Path
from typing import List, Dict

def validate_data(data: Dict) -> bool:
    """验证数据完整性"""
    required_fields = ["problem", "thinking", "solution"]
    return all(field in data and data[field] for field in required_fields)

def format_prompt(problem: str, thinking: str, solution: str, 
                  template_type: str = "default") -> str:
    """
    支持多种提示词模板
    """
    templates = {
        "default": (
            f"User: {problem}\n\n"
            f"Assistant: <|thought|>\n{thinking}\n"
            f"<|solution|>\n{solution}"
        ),
        "chatml": (
            f"<|im_start|>user\n{problem}<|im_end|>\n"
            f"<|im_start|>assistant\n<|thought|>\n{thinking}\n"
            f"<|solution|>\n{solution}<|im_end|>"
        ),
        "raw": f"{problem}\n\n思考:{thinking}\n\n答案:{solution}"
    }
    return templates.get(template_type, templates["default"])

def split_dataset(data: List[Dict], train_ratio: float = 0.9, 
                  seed: int = 42) -> tuple:
    """划分训练集和验证集"""
    random.seed(seed)
    random.shuffle(data)
    split_idx = int(len(data) * train_ratio)
    return data[:split_idx], data[split_idx:]

def convert_dataset(input_path: str, output_dir: str, 
                    template_type: str = "default",
                    train_ratio: float = 0.9):
    """
    主转换函数
    """
    input_file = Path(input_path)
    output_path = Path(output_dir)
    output_path.mkdir(parents=True, exist_ok=True)
    
    # 读取原始数据
    raw_data = []
    with open(input_file, 'r', encoding='utf-8') as f:
        for line_num, line in enumerate(f, 1):
            try:
                data = json.loads(line.strip())
                if validate_data(data):
                    raw_data.append(data)
                else:
                    print(f"⚠️  跳过第 {line_num} 行:字段缺失")
            except json.JSONDecodeError:
                print(f"❌ 解析错误第 {line_num} 行")
    
    print(f"📊 有效数据: {len(raw_data)} 条")
    
    # 划分数据集
    train_data, valid_data = split_dataset(raw_data, train_ratio)
    print(f"📦 训练集: {len(train_data)} 条 | 验证集: {len(valid_data)} 条")
    
    # 转换并保存
    def save_split(data: List[Dict], filename: str):
        output_file = output_path / filename
        with open(output_file, 'w', encoding='utf-8') as f:
            for item in data:
                formatted = format_prompt(
                    item["problem"], 
                    item["thinking"], 
                    item["solution"],
                    template_type
                )
                # 统计长度(用于后续分析)
                token_len = len(formatted) // 4  # 粗略估算
                record = {
                    "text": formatted,
                    "metadata": {
                        "original_id": item.get("id", ""),
                        "token_estimate": token_len
                    }
                }
                json.dump(record, f, ensure_ascii=False)
                f.write('\n')
        print(f"✅ 已保存: {output_file}")
    
    save_split(train_data, "train.jsonl")
    if valid_data:
        save_split(valid_data, "valid.jsonl")
    
    # 生成数据报告
    lengths = [len(item["text"]) for item in raw_data]
    print(f"\n📈 数据统计:")
    print(f"   平均长度: {sum(lengths)/len(lengths):.0f} 字符")
    print(f"   最大长度: {max(lengths)} 字符")
    print(f"   最小长度: {min(lengths)} 字符")

if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--input", required=True, help="输入 JSONL 文件")
    parser.add_argument("--output", default="./data", help="输出目录")
    parser.add_argument("--template", default="default", 
                       choices=["default", "chatml", "raw"])
    parser.add_argument("--train-ratio", type=float, default=0.9)
    
    args = parser.parse_args()
    convert_dataset(args.input, args.output, args.template, args.train_ratio)

运行命令

python convert_dataset.py \
    --input ./raw_data/dataset.jsonl \
    --output ./data \
    --template default \
    --train-ratio 0.95  # 留 5% 做验证

4.4 数据质量检查清单

执行转换后,务必检查:

# 1. 检查文件格式
head -n 1 ./data/train.jsonl | python -m json.tool

# 2. 统计行数(应与原始数据匹配)
wc -l ./data/train.jsonl ./data/valid.jsonl

# 3. 检查文本长度分布(避免过长数据)
python -c "
import json
lengths = []
with open('./data/train.jsonl') as f:
    for line in f:
        data = json.loads(line)
        lengths.append(len(data['text']))
print(f'Max: {max(lengths)}, Min: {min(lengths)}, Avg: {sum(lengths)/len(lengths):.0f}')
"

⚠️ 关键警告

  • 序列长度:MLX 默认 max_seq_length=2048,超过会被截断
  • 截断风险:如果截断发生在答案部分,模型将学不到完整输出
  • 建议:预处理阶段过滤掉 >3000 字符的样本,或手动截断到合理长度

第五章:LoRA 训练实战

5.1 训练参数详解

python -m mlx_lm.lora \
    --model ./models/Qwen2.5-1.5B-Instruct \
    --train \
    --data ./data \
    --iters 1000 \
    --batch-size 1 \
    --steps-per-report 10 \
    --adapter-path ./output/qwen_reasoning_adapter \
    --learning-rate 1e-5 \
    --lora-rank 8 \
    --lora-alpha 16 \
    --lora-dropout 0.05 \
    --max-seq-length 2048 \
    --save-every 100

参数深度解析

参数含义调参建议
--lora-rank低秩矩阵维度8-64,越大表达能力越强,但易过拟合
--lora-alpha缩放系数通常设为 rank 的 2 倍
--learning-rate学习率1e-5 到 5e-5,过大导致不稳定
--batch-size批次大小Mac 上保持 1,显存充足可增大
--max-seq-length最大序列长度根据数据长度调整,需留余量

5.2 训练过程监控

正常训练日志解读

Iter 10: Train loss 1.435, Learning Rate 1.000e-05, It/sec 0.326...

关键指标

  • Train loss:应持续下降,最终稳定在 0.5-1.5 之间
  • It/sec:迭代速度,Mac M3 约 0.3-0.5 it/s
  • Peak mem:内存峰值,超过物理内存会触发 SWAP 导致极慢

危险信号

  • loss = nan:学习率过大或梯度爆炸,立即停训
  • loss 不降反升:学习率过高或数据有问题
  • loss 降到 0.01 以下:严重过拟合,模型变成复读机

5.3 内存优化策略

如果训练时内存不足:

# 方案 1:开启梯度检查点(牺牲速度换内存)
--gradient-checkpointing

# 方案 2:使用量化模型作为基础
--model Qwen/Qwen2.5-1.5B-Instruct-4bit

# 方案 3:减小序列长度(确保覆盖 90% 数据即可)
--max-seq-length 1024

第六章:模型评估与推理测试

6.1 使用 Adapter 进行推理

python -m mlx_lm.generate \
    --model ./models/Qwen2.5-1.5B-Instruct \
    --adapter-path ./output/qwen_reasoning_adapter \
    --prompt "User: 一个水池有两个进水管,A管单独注满需6小时,B管单独注满需4小时,同时打开两管需几小时注满?\n\nAssistant: <|thought|>" \
    --max-tokens 500 \
    --temperature 0.3

对比测试建议

  1. 先用基础模型(不加 --adapter-path)测试同一问题
  2. 再用微调后的模型测试
  3. 记录两者差异,评估微调效果

6.2 批量评估脚本

# evaluate_model.py
import subprocess
import json

def evaluate(test_cases: list, adapter_path: str = None):
    results = []
    base_cmd = [
        "python", "-m", "mlx_lm.generate",
        "--model", "./models/Qwen2.5-1.5B-Instruct",
        "--max-tokens", "500",
        "--temperature", "0.3"
    ]
    
    if adapter_path:
        base_cmd.extend(["--adapter-path", adapter_path])
    
    for case in test_cases:
        prompt = f"User: {case}\n\nAssistant: <|thought|>"
        cmd = base_cmd + ["--prompt", prompt]
        
        result = subprocess.run(cmd, capture_output=True, text=True)
        output = result.stdout.strip()
        
        results.append({
            "input": case,
            "output": output,
            "has_thought": "<|thought|>" in output,
            "has_solution": "<|solution|>" in output
        })
    
    return results

# 运行对比
test_cases = [
    "鸡兔同笼,头共35,脚共94,鸡兔各几只?",
    "计算 125 × 32 的简便方法"
]

base_results = evaluate(test_cases)
lora_results = evaluate(test_cases, "./output/qwen_reasoning_adapter")

# 分析:lora_results 应显示更规范的思考步骤

6.3 模型融合(可选)

如果希望部署独立模型(无需每次加载 adapter):

python -m mlx_lm.fuse \
    --model ./models/Qwen2.5-1.5B-Instruct \
    --adapter-path ./output/qwen_reasoning_adapter \
    --save-path ./models/Qwen-1.5B-Reasoning-Fused \
    --de-quantize  # 如果需要恢复为 FP16 精度

融合 vs Adapter 对比

方式优点缺点
Adapter 分离体积小(~50MB)、可多版本切换加载时需合并,略慢
融合模型单一文件、部署简单体积大(~3GB)、版本管理难

第七章:生产环境部署建议

7.1 模型量化(优化推理速度)

# 将融合后的模型量化为 4-bit(适合移动端部署)
python -m mlx_lm.convert \
    --hf-path ./models/Qwen-1.5B-Reasoning-Fused \
    --mlx-path ./models/Qwen-1.5B-Reasoning-4bit \
    --quantize --q-bits 4

7.2 持续迭代流程

原始数据 → 清洗过滤 → 格式转换 → 训练 → 评估 →  bad cases 回流 → 数据增强 → 重新训练
     ↑___________________________________________________________|

7.3 关键检查点总结

阶段必做检查常见问题
环境准备mlx-lm 版本 >= 0.8旧版本 API 不兼容
数据转换验证 JSONL 格式正确性特殊字符未转义导致解析失败
训练启动确认加载了验证集过拟合无法及时发现
训练过程监控 loss 曲线学习率过大导致 nan
推理测试对比基线模型模板格式与训练时不一致

附录:故障排查速查表

现象可能原因解决方案
RuntimeError: MPS out of memory序列过长或 batch size 过大减小 --max-seq-length--batch-size
KeyError: 'text'数据格式错误检查 JSONL 是否包含 text 字段
Loss 不下降学习率过低或数据质量问题增大 lr 至 5e-5,检查数据标签
生成结果乱码分词器不匹配确保使用与训练时相同的 tokenizer
模型不遵循指令模板格式不一致推理 prompt 必须与训练模板完全匹配

【AI】RAG技术介绍与实操

【AI】RAG技术介绍与实操

本文介绍了AI领域RAG增强检索生成技术的介绍和实操记录

从零开始构建本地RAG系统:基于Ollama的DeepSeek-R1与Nomic-Embedd实战

大语言模型的出现改变了我们与信息交互的方式,但这些模型的知识局限于训练数据的截止日期,无法覆盖实时信息和企业私有文档。检索增强生成(Retrieval-Augmented Generation, RAG)通过为模型配备外部知识库,巧妙地解决了这一难题。本文将带领你从零开始,使用Ollama平台拉取DeepSeek-R1推理模型和Nomic-Embed-Text向量化模型,构建一个完整的本地RAG系统。

一、RAG的出现:解决大模型的知识困境

1.1 大模型的固有局限

大型语言模型虽然在语言理解和生成方面表现惊艳,但它们存在两个根本性缺陷:

知识时效性问题:模型的训练数据有严格的截止日期,对于新近发生的事件或最新研究成果一无所知。GPT-4的知识截止于2023年,当你询问2024年的新闻时,它要么无法回答,要么提供过时信息。

私有数据缺失问题:公开训练数据无法覆盖企业的内部文档、产品手册或个人知识库。如果你想让模型回答关于公司内部流程的问题,仅靠通用模型是无能为力的。

更严重的是,当模型面对超出其知识范围的问题时,它不会坦诚地说”不知道”,而是会产生”幻觉”(Hallucination)——自信地编造出看似合理但完全错误的信息。这在企业应用中是不可接受的。

1.2 RAG的核心思想:开卷考试

RAG的核心理念很简单:在回答问题前,先让模型”查阅资料”。就像开卷考试允许学生翻阅教材,RAG系统在执行生成任务前,会从一个外部知识库中检索出最相关的信息,然后将这些信息作为上下文提供给模型。

这种方法带来的优势是显著的:

  • 事实准确性提升:模型的回答基于检索到的真实信息,而非凭空编造
  • 知识可更新:只需更新知识库,无需重新训练模型
  • 来源可追溯:系统可以提供答案的信息来源,增强可信度
  • 领域适应性:通过更换知识库,同一个模型可以适配不同专业领域

RAG最早由Facebook AI Research在2020年提出,他们使用维基百科作为外部知识库,通过Dense Passage Retrieval(DPR)技术检索相关文本片段,然后输入给BART生成模型。自那时起,RAG迅速普及,成为AI应用开发的核心范式。

二、RAG向量化流程详解

RAG系统的核心在于将非结构化文本转换为机器可计算的向量表示。这一过程涉及文档加载、文本切分、向量化生成和向量存储四个关键步骤。

2.1 文档加载与切分

原始知识库通常是各种格式的文档——TXT、PDF、Markdown或网页。构建RAG的第一步是加载这些文档并将其切分成适合检索的文本块。

为什么需要切分? 大模型有上下文长度限制,直接将整本手册或整篇文章作为上下文既不现实也不高效。切分的目标是:每个文本块应包含相对完整的语义单元

切分策略有多种选择:

按换行符切分是最简单直接的方式,适用于行结构清晰的文档:

def split_content(content):
    chunks = []
    lines = content.splitlines()
    for line in lines:
        if line.strip():  # 忽略空行
            chunks.append(line)
    return chunks

递归字符文本切分更智能,它会尝试按段落、句子、单词的顺序逐步切分,尽可能保持语义完整性。实践中,chunk_size通常设为500-1000个字符,chunk_overlap设为50-100个字符,以保留上下文连贯性。

2.2 向量化:文本的数学表达

切分后的文本块需要转换为计算机可以理解和计算的形式——向量(Vector),也称为嵌入(Embedding)

向量的本质可以这样理解:想象我们要给每段文字拍一张特殊的”照片”,这张照片由几百个数字组成(如768个维度)。这张”照片”就是该段文字的”语义身份证”——语义相近的文字,它们的”照片”在数字空间中的距离也会很近。

这个转换过程由嵌入模型(Embedding Model) 完成。嵌入模型经过海量文本训练,学会了将文字映射到高维语义空间。在本文的实践中,我们将使用Ollama拉取的nomic-embed-text模型,它生成的向量维度为768维。

向量化的核心原则是:语义相似,向量相近。这意味着:

  • “苹果是一种水果”和”香蕉富含钾元素”的向量距离较近
  • “苹果是一种水果”和”Python是一种编程语言”的向量距离较远

2.3 向量存储

生成向量后,我们需要一个专门的基础设施来存储和检索这些向量。虽然传统数据库(如MySQL)也能存储向量,但它们无法进行高效的相似性搜索。

向量数据库(Vector Database) 专为存储和查询高维向量而设计,内置了高效的近似最近邻(ANN)搜索算法,可以在毫秒级内从数百万向量中找出最相似的几个。

常见的向量数据库包括FAISS、ChromaDB、Milvus等。在本文的实践中,考虑到我们追求理解原理而非生产部署,将使用FAISS或甚至直接使用NumPy数组存储向量——这足以让我们看清RAG的本质。

三、RAG推理检索流程

当用户提出问题后,RAG系统进入在线推理阶段。这一阶段包括查询向量化、相似度检索、结果重排和提示词构建四个环节。

3.1 查询向量化与相似度检索

用户输入的问题同样需要转换为向量——必须使用与知识库向量化完全相同的嵌入模型。这样才能确保查询向量和文档向量位于同一个语义空间,距离计算才有意义。

接下来,系统计算查询向量与知识库中所有文档向量的相似度。最常用的相似度指标是余弦相似度(Cosine Similarity)

def similarity(e1, e2):
    # 计算余弦相似度
    dot_product = np.dot(e1, e2)                     # 点乘
    norm_e1 = np.linalg.norm(e1)                     # 向量1的范数
    norm_e2 = np.linalg.norm(e2)                     # 向量2的范数
    cosine_sim = dot_product / (norm_e1 * norm_e2)   # 余弦相似度
    return cosine_sim

余弦相似度的取值范围是[-1, 1],值越大表示两个向量的夹角越小,语义越相近。

计算出所有相似度后,系统按分数从高到低排序,取前K个(通常K=3~5)最相似的文本块作为检索结果。

3.2 结果重排(可选优化)

基础的向量检索存在一个潜在问题:排名靠前的文档不一定是最有用的。检索阶段使用的是双编码器(Bi-Encoder) 架构,它将查询和文档分别编码,速度快但精度有限。

为了提高质量,可以引入重排(Reranking) 阶段。重排使用交叉编码器(Cross-Encoder) 架构,它将查询和文档拼接后一起输入模型,计算相关性得分,精度更高但速度慢。因此,典型的策略是:向量检索先快速召回Top 100,重排模型再从中精筛Top 5。

在本文的简化实现中,我们将跳过重排步骤,直接使用向量检索结果。

3.3 提示词构建与生成

检索到相关文档后,需要将它们与用户问题组装成一个结构化的提示词(Prompt),然后发送给大语言模型。

最简单的提示词模板如下:

prompt_template = """
基于以下知识回答问题:

知识:
1: %s
2: %s
3: %s
4: %s
5: %s

问题:%s

请基于上述知识给出准确、详细的回答。如果知识中不包含相关信息,请明确说明。
"""

这个模板的设计原则是:

  • 明确信息来源:告知模型知识是从哪里来的
  • 限定回答范围:要求模型”基于知识”回答,减少幻觉
  • 允许不知道:为模型提供”不知道”的出口,避免编造

将组装好的提示词发送给大语言模型(本文使用DeepSeek-R1),模型生成的回答就是最终输出。

四、输出整理与优化

RAG系统的输出并非终点,还需要进行整理和可能的优化。

4.1 引用来源

高质量的RAG系统应该在回答中标注信息来源。这类似于学术论文的脚注,用户可以核实每个事实的真实性。在实践中,可以在返回检索结果时保留文档的元数据(如文件名、段落位置),然后在生成回答时要求模型引用来源编号。

4.2 查询重写(高级优化)

用户提问往往含糊不清或包含隐含意图。例如,用户问”告诉我NVIDIA模型的最新更新”,可能暗中对特定功能感兴趣,但这种偏好没有被明确表达。

查询重写(Query Rewriting) 技术可以在检索前优化用户查询,弥合用户提问方式与知识库信息结构之间的语义差距。常用方法包括:

  • Q2E(Query2Expand):生成同义词和相关短语,扩展查询
  • Q2D(Query2Doc):根据查询构建伪文档,匹配文档风格
  • CoT(思维链)查询重写:让模型逐步推理,分解查询意图

研究表明,使用Llama 3.3 Nemotron Super 49B进行CoT查询重写后,检索准确率@10从43.1%提升至63.8%。

五、串联运行:完整Python脚本实现

现在,让我们将所有环节串联起来,编写一个完整的RAG系统Python脚本。本实现将最小化第三方框架依赖,仅使用Ollama和NumPy,确保你能够看清每个步骤的本质。

5.1 环境准备

首先,确保已安装Ollama并拉取所需模型:

# 安装Ollama(请访问ollama.com下载对应系统版本)

# 拉取模型
ollama pull deepseek-r1:8b
ollama pull nomic-embed-text

# 安装Python依赖
pip install ollama numpy

环境问题

系统python 3.9缺失

这个错误是因为Python 3.9不支持|操作符用于类型联合(type union)。在Python 3.10及以上版本中,|才被支持用于类型提示。你的代码运行在Python 3.9环境,所以报错。

需要安装python 3.10.X 使用pyenv工具,切换用户层级的python环境,

Rust编译器缺失:安装tiktoken时报错can’t find Rust compiler

SWIG工具缺失:报错command ‘swig’ failed: No such file or directory

FAISS编译失败:报错command ‘/opt/homebrew/bin/swig’ failed with exit code 1,FAISS安装失败

PyPy兼容性问题

发现你在使用PyPy而不是CPython,导致安装faiss和chromadb均报错。

重置步骤:

# 1. 确保使用 CPython
pyenv install 3.10.15
cd /Users/mac/Dev/Desktop/RagDemo
pyenv local 3.10.15

# 2. 创建干净的环境
python -m venv venv
source venv/bin/activate

# 3. 升级基础工具
pip install --upgrade pip setuptools wheel

# 4. 安装依赖(按顺序)
pip install chromadb
pip install llama-index-core
pip install llama-index-embeddings-ollama
pip install llama-index-llms-ollama
pip install llama-index-vector-stores-chroma

# 5. 验证安装
pip list | grep -E "chroma|llama"

5.2 完整代码实现

"""
基于LlamaIndex的多文件RAG系统
处理文件夹下所有文件,支持多种文件格式
显示DeepSeek-R1的完整思考过程
"""

import os
from typing import List, Generator, Optional
import warnings
warnings.filterwarnings('ignore')

# LlamaIndex核心组件
from llama_index.core import (
    SimpleDirectoryReader,
    VectorStoreIndex,
    Settings,
    Document
)
from llama_index.core.node_parser import SentenceSplitter
from llama_index.core.retrievers import VectorIndexRetriever
from llama_index.core.query_engine import RetrieverQueryEngine
from llama_index.core.response_synthesizers import CompactAndRefine
from llama_index.core.postprocessor import SimilarityPostprocessor
from llama_index.core.storage import StorageContext

# Ollama集成
from llama_index.embeddings.ollama import OllamaEmbedding
from llama_index.llms.ollama import Ollama

# 向量存储
from llama_index.vector_stores.faiss import FaissVectorStore
import faiss

# 文件监控(可选)
import time
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler


class MultiFileRAGSystem:
    """
    多文件RAG系统
    支持文件夹下所有文件,自动处理格式转换
    """

    SUPPORTED_EXTENSIONS = [
        '.txt', '.pdf', '.docx', '.pptx', '.xlsx',
        '.md', '.csv', '.epub', '.html', '.htm',
        '.json', '.xml', '.ipynb'
    ]

    def __init__(
        self,
        docs_dir: str,
        embed_model_name: str = "nomic-embed-text",
        llm_model_name: str = "deepseek-r1:8b",
        ollama_base_url: str = "http://localhost:11434",
        chunk_size: int = 512,
        chunk_overlap: int = 50,
        similarity_top_k: int = 5,
        persist_dir: Optional[str] = "./storage"
    ):
        """
        初始化多文件RAG系统

        Args:
            docs_dir: 文档目录路径
            embed_model_name: 嵌入模型名称
            llm_model_name: 大语言模型名称
            ollama_base_url: Ollama服务地址
            chunk_size: 文本块大小
            chunk_overlap: 文本块重叠大小
            similarity_top_k: 检索返回的相似文档数量
            persist_dir: 索引持久化目录(可选)
        """
        self.docs_dir = docs_dir
        self.persist_dir = persist_dir
        self.similarity_top_k = similarity_top_k
        self.thinking_buffer = ""
        self.answer_buffer = ""

        print("=" * 80)
        print("多文件RAG系统初始化")
        print("=" * 80)
        print(f"📁 文档目录: {docs_dir}")
        print(f"📊 支持的文件格式: {', '.join(self.SUPPORTED_EXTENSIONS[:5])}等")

        # 检查目录是否存在
        if not os.path.exists(docs_dir):
            raise ValueError(f"文档目录不存在: {docs_dir}")

        # 1. 配置嵌入模型
        print(f"\n🔧 加载嵌入模型: {embed_model_name}")
        self.embed_model = OllamaEmbedding(
            model_name=embed_model_name,
            base_url=ollama_base_url,
            ollama_additional_kwargs={"mirostat": 0}
        )

        # 2. 配置大语言模型
        print(f"🔧 加载语言模型: {llm_model_name}")
        self.llm = Ollama(
            model=llm_model_name,
            base_url=ollama_base_url,
            temperature=0.7,
            request_timeout=120.0,
            additional_kwargs={
                "num_predict": 2048,
                "top_k": 40,
                "top_p": 0.9
            }
        )

        # 3. 设置全局配置
        Settings.embed_model = self.embed_model
        Settings.llm = self.llm
        Settings.chunk_size = chunk_size
        Settings.chunk_overlap = chunk_overlap

        # 4. 初始化文本切分器
        self.node_parser = SentenceSplitter(
            chunk_size=chunk_size,
            chunk_overlap=chunk_overlap,
            separator=" ",
            paragraph_separator="\n\n",
            secondary_chunking_regex="[^,.;:]+[,.;:]?"
        )

        # 5. 索引对象
        self.index = None
        self.query_engine = None

        # 6. 文件统计信息
        self.file_stats = {}

        print("✅ 初始化完成")

    def scan_documents(self) -> dict:
        """
        扫描文档目录,统计文件信息

        Returns:
            文件统计信息
        """
        print(f"\n📋 扫描文档目录: {self.docs_dir}")

        stats = {
            "total_files": 0,
            "supported_files": 0,
            "unsupported_files": 0,
            "file_types": {},
            "total_size_mb": 0
        }

        for root, dirs, files in os.walk(self.docs_dir):
            for file in files:
                file_path = os.path.join(root, file)
                file_ext = os.path.splitext(file)[1].lower()
                file_size = os.path.getsize(file_path) / (1024 * 1024)  # MB

                stats["total_files"] += 1
                stats["total_size_mb"] += file_size

                if file_ext in self.SUPPORTED_EXTENSIONS:
                    stats["supported_files"] += 1
                    stats["file_types"][file_ext] = stats["file_types"].get(file_ext, 0) + 1
                    print(f"  ✅ {file} ({file_size:.2f} MB) - {file_ext}")
                else:
                    stats["unsupported_files"] += 1
                    print(f"  ⚠️ {file} ({file_size:.2f} MB) - 不支持格式: {file_ext}")

        print(f"\n📊 统计结果:")
        print(f"  总文件数: {stats['total_files']}")
        print(f"  支持文件: {stats['supported_files']}")
        print(f"  不支持文件: {stats['unsupported_files']}")
        print(f"  总大小: {stats['total_size_mb']:.2f} MB")
        print(f"  文件类型分布: {stats['file_types']}")

        self.file_stats = stats
        return stats

    def load_documents(self, recursive: bool = True) -> List[Document]:
        """
        加载文档目录下的所有文件

        Args:
            recursive: 是否递归加载子目录

        Returns:
            文档列表
        """
        print(f"\n📄 加载文档...")

        # 首先扫描文件
        self.scan_documents()

        # 使用SimpleDirectoryReader加载所有文件
        reader = SimpleDirectoryReader(
            input_dir=self.docs_dir,
            recursive=recursive,
            exclude_hidden=True,  # 排除隐藏文件
            required_exts=self.SUPPORTED_EXTENSIONS  # 只加载支持的文件
        )

        documents = reader.load_data()

        print(f"\n✅ 成功加载 {len(documents)} 个文档")

        # 按文件类型显示统计
        doc_by_type = {}
        for doc in documents:
            file_name = doc.metadata.get('file_name', 'unknown')
            file_ext = os.path.splitext(file_name)[1].lower()
            doc_by_type[file_ext] = doc_by_type.get(file_ext, 0) + 1

        print("📊 文档类型分布:")
        for ext, count in doc_by_type.items():
            print(f"  {ext}: {count} 个文档")

        # 显示每个文档的信息
        for i, doc in enumerate(documents[:5]):  # 只显示前5个
            file_name = doc.metadata.get('file_name', 'unknown')
            file_size = doc.metadata.get('file_size', 0) / 1024  # KB
            preview = doc.text[:100].replace('\n', ' ') + "..." if len(doc.text) > 100 else doc.text
            print(f"\n  文档 {i+1}: {file_name}")
            print(f"    大小: {file_size:.1f} KB")
            print(f"    预览: {preview}")

        if len(documents) > 5:
            print(f"\n  ... 还有 {len(documents) - 5} 个文档未显示")

        return documents

    def build_index(self, documents: List[Document], force_rebuild: bool = False):
        """
        构建向量索引

        Args:
            documents: 文档列表
            force_rebuild: 是否强制重建(忽略已有索引)
        """
        # 检查是否有持久化索引
        if self.persist_dir and os.path.exists(self.persist_dir) and not force_rebuild:
            try:
                print(f"\n🔍 发现已有索引,尝试加载...")
                self.load_index()
                return
            except Exception as e:
                print(f"⚠️ 加载索引失败: {e}")
                print("将重新构建索引...")

        print(f"\n🔨 构建向量索引...")
        print(f"切分文档为文本块 (size={Settings.chunk_size}, overlap={Settings.chunk_overlap})")

        # 构建索引(显示进度)
        self.index = VectorStoreIndex.from_documents(
            documents,
            embed_model=self.embed_model,
            node_parser=self.node_parser,
            show_progress=True
        )

        print(f"✅ 索引构建完成")

        # 获取节点统计
        nodes = self.index.docstore.docs.values()
        print(f"  生成文本块数量: {len(nodes)}")

        # 计算平均文本块长度
        avg_length = sum(len(node.text) for node in nodes) / len(nodes)
        print(f"  平均文本块长度: {avg_length:.1f} 字符")

        # 保存索引
        if self.persist_dir:
            self.save_index()

    def setup_query_engine(self):
        """
        配置查询引擎
        """
        if not self.index:
            raise ValueError("请先构建索引")

        print(f"\n⚙️ 配置查询引擎...")

        # 1. 创建检索器
        retriever = VectorIndexRetriever(
            index=self.index,
            similarity_top_k=self.similarity_top_k,
            embed_model=self.embed_model
        )

        # 2. 创建响应合成器(启用流式输出)
        response_synthesizer = CompactAndRefine(
            llm=self.llm,
            streaming=True,
            verbose=True
        )

        # 3. 创建后处理器
        postprocessor = SimilarityPostprocessor(similarity_cutoff=0.6)  # 降低阈值以包含更多结果

        # 4. 组合为查询引擎
        self.query_engine = RetrieverQueryEngine(
            retriever=retriever,
            response_synthesizer=response_synthesizer,
            node_postprocessors=[postprocessor]
        )

        print(f"✅ 查询引擎配置完成")
        print(f"  检索数量: top_{self.similarity_top_k}")
        print(f"  相似度阈值: 0.6")

    def query_with_thinking(self, query: str) -> Generator[dict, None, None]:
        """
        执行查询并流式返回思考过程

        Args:
            query: 用户查询

        Yields:
            包含类型和内容的字典
        """
        if not self.query_engine:
            raise ValueError("请先配置查询引擎")

        print(f"\n🔍 执行查询: '{query}'")

        # 1. 执行检索
        nodes = self.query_engine.retriever.retrieve(query)

        # 组织检索结果
        retrieval_results = []
        for node in nodes:
            file_name = node.metadata.get('file_name', '未知文件')
            retrieval_results.append({
                "text": node.text,
                "score": node.score,
                "file_name": file_name,
                "node_id": node.node_id
            })

        # 显示检索结果
        yield {
            "type": "retrieval",
            "content": {
                "query": query,
                "results": retrieval_results
            }
        }

        # 2. 构建提示词(带文件来源信息)
        context_parts = []
        for i, node in enumerate(nodes):
            file_name = node.metadata.get('file_name', '未知文件')
            context_parts.append(f"[来自文件: {file_name}]\n{node.text}")

        context_text = "\n\n---\n\n".join(context_parts)
        prompt = f"""
基于以下从多个文件中检索到的知识回答问题。每个知识片段都标注了来源文件。

{context_text}

用户问题:{query}

请严格基于上述知识回答,不要编造信息。如果知识中不包含相关信息,请明确说明。
在回答中可以提及信息来源文件(如"根据XXX文件的描述")。
"""

        yield {"type": "prompt", "content": prompt[:300] + "..." if len(prompt) > 300 else prompt}

        # 3. 执行查询(流式输出)
        yield {"type": "start_generation", "content": "开始生成回答..."}

        streaming_response = self.query_engine.query(query)

        # 重置缓冲区
        self.thinking_buffer = ""
        self.answer_buffer = ""
        in_thinking = True

        # 流式处理响应
        for text_chunk in streaming_response.response_gen:
            # 检测思考/回答边界
            if "思考:" in text_chunk or "分析:" in text_chunk:
                in_thinking = True
                self.thinking_buffer += text_chunk
                yield {"type": "thinking_chunk", "content": text_chunk}
            elif "答案:" in text_chunk or "回答:" in text_chunk:
                if in_thinking:
                    yield {"type": "thinking_end", "content": ""}
                in_thinking = False
                self.answer_buffer += text_chunk
                yield {"type": "answer_chunk", "content": text_chunk}
            elif in_thinking:
                self.thinking_buffer += text_chunk
                yield {"type": "thinking_chunk", "content": text_chunk}
            else:
                self.answer_buffer += text_chunk
                yield {"type": "answer_chunk", "content": text_chunk}

        # 4. 返回完整结果(带来源信息)
        source_nodes = [
            {
                "text": node.text,
                "score": node.score,
                "file_name": node.metadata.get('file_name', '未知文件'),
                "metadata": node.metadata
            }
            for node in streaming_response.source_nodes
        ]

        yield {
            "type": "complete",
            "content": {
                "thinking": self.thinking_buffer,
                "answer": streaming_response.response,
                "source_nodes": source_nodes
            }
        }

    def save_index(self):
        """保存索引到磁盘"""
        if not self.index or not self.persist_dir:
            return

        os.makedirs(self.persist_dir, exist_ok=True)
        self.index.storage_context.persist(persist_dir=self.persist_dir)
        print(f"✅ 索引已保存到 {self.persist_dir}")

    def load_index(self):
        """从磁盘加载索引"""
        from llama_index.core import load_index_from_storage

        if not os.path.exists(self.persist_dir):
            raise ValueError(f"持久化目录不存在: {self.persist_dir}")

        storage_context = StorageContext.from_defaults(persist_dir=self.persist_dir)
        self.index = load_index_from_storage(storage_context)
        print(f"✅ 索引已从 {self.persist_dir} 加载")


class DocumentWatcher(FileSystemEventHandler):
    """文档目录监控器,当文件变化时自动重建索引"""

    def __init__(self, rag_system: MultiFileRAGSystem):
        self.rag_system = rag_system
        self.last_rebuild = time.time()
        self.rebuild_cooldown = 60  # 60秒内不重复重建

    def on_modified(self, event):
        if not event.is_directory:
            self._handle_change(event.src_path)

    def on_created(self, event):
        if not event.is_directory:
            self._handle_change(event.src_path)

    def on_deleted(self, event):
        if not event.is_directory:
            self._handle_change(event.src_path)

    def _handle_change(self, file_path):
        """处理文件变化"""
        current_time = time.time()
        if current_time - self.last_rebuild > self.rebuild_cooldown:
            print(f"\n🔄 检测到文件变化: {file_path}")
            print("正在重建索引...")
            self.rag_system.build_index(
                self.rag_system.load_documents(),
                force_rebuild=True
            )
            self.last_rebuild = current_time


def interactive_mode():
    """交互式问答模式"""

    # 配置
    docs_dir = "./blog_docs"  # 存放所有知识库文件的目录

    # 初始化RAG系统
    rag = MultiFileRAGSystem(
        docs_dir=docs_dir,
        embed_model_name="nomic-embed-text",
        llm_model_name="deepseek-r1:8b",
        chunk_size=512,
        chunk_overlap=50,
        similarity_top_k=5,
        persist_dir="./storage"  # 启用持久化
    )

    # 加载文档并构建索引
    documents = rag.load_documents(recursive=True)
    rag.build_index(documents)
    rag.setup_query_engine()

    # 交互式问答
    print("\n" + "=" * 80)
    print("多文件RAG系统已就绪!输入问题开始问答(输入 'exit' 退出)")
    print("=" * 80)

    while True:
        query = input("\n📝 【用户】: ").strip()
        if query.lower() in ['exit', 'quit', '退出']:
            print("再见!")
            break

        if not query:
            continue

        print("\n🔄 【系统】正在处理...")
        print("-" * 80)

        thinking_mode = True
        sources_shown = False

        for event in rag.query_with_thinking(query):
            if event["type"] == "retrieval":
                print("\n📚 【检索结果】")
                for i, result in enumerate(event["content"]["results"]):
                    preview = result["text"][:60] + "..." if len(result["text"]) > 60 else result["text"]
                    print(f"  [{i+1}] 相关度: {result['score']:.4f} | 来源: {result['file_name']}")
                    print(f"      内容: {preview}")

            elif event["type"] == "prompt":
                print(f"\n📝 【提示词预览】\n  {event['content']}")

            elif event["type"] == "start_generation":
                print("\n🧠 【模型思考中】")

            elif event["type"] == "thinking_chunk":
                print(event["content"], end='', flush=True)

            elif event["type"] == "thinking_end":
                print("\n\n💡 【思考完成,开始回答】\n")
                thinking_mode = False

            elif event["type"] == "answer_chunk":
                print(event["content"], end='', flush=True)

            elif event["type"] == "complete" and not sources_shown:
                print("\n\n📊 【信息来源】")
                sources = {}
                for node in event["content"]["source_nodes"]:
                    file_name = node["file_name"]
                    if file_name not in sources:
                        sources[file_name] = []
                    sources[file_name].append({
                        "score": node["score"],
                        "preview": node["text"][:50] + "..."
                    })

                for file_name, snippets in sources.items():
                    print(f"\n  📄 {file_name}:")
                    for i, snippet in enumerate(snippets):
                        print(f"    [{i+1}] 相关度: {snippet['score']:.4f}")
                        print(f"        {snippet['preview']}")

                sources_shown = True

        print("\n" + "=" * 80)


def batch_mode():
    """批量处理模式:处理多个问题"""

    # 配置
    docs_dir = "./blog_docs"

    rag = MultiFileRAGSystem(
        docs_dir=docs_dir,
        persist_dir="./storage"
    )

    # 加载文档并构建索引
    documents = rag.load_documents()
    rag.build_index(documents)
    rag.setup_query_engine()

    # 从文件读取问题列表
    questions_file = "questions.txt"
    if os.path.exists(questions_file):
        with open(questions_file, 'r', encoding='utf-8') as f:
            questions = [q.strip() for q in f.readlines() if q.strip()]
    else:
        # 示例问题
        questions = [
            "苹果有什么营养价值?",
            "香蕉适合什么时候吃?",
            "如何保持健康饮食?"
        ]

    print("\n" + "=" * 80)
    print("批量处理模式")
    print("=" * 80)

    results = []
    for i, question in enumerate(questions, 1):
        print(f"\n📌 问题 {i}/{len(questions)}: {question}")
        print("-" * 40)

        response = rag.query_engine.query(question)
        print(f"回答: {response}")

        results.append({
            "question": question,
            "answer": str(response),
            "sources": [
                {
                    "file": node.metadata.get('file_name', '未知'),
                    "score": node.score,
                    "text": node.text[:200]
                }
                for node in response.source_nodes
            ]
        })

        print()

    # 保存结果
    import json
    with open("batch_results.json", "w", encoding='utf-8') as f:
        json.dump(results, f, ensure_ascii=False, indent=2)
    print("✅ 结果已保存到 batch_results.json")


def watch_mode():
    """监控模式:监听文件变化并自动更新索引"""

    # 配置
    docs_dir = "./blog_docs"

    rag = MultiFileRAGSystem(
        docs_dir=docs_dir,
        persist_dir="./storage"
    )

    # 初始构建
    documents = rag.load_documents()
    rag.build_index(documents)
    rag.setup_query_engine()

    # 启动文件监控
    event_handler = DocumentWatcher(rag)
    observer = Observer()
    observer.schedule(event_handler, docs_dir, recursive=True)
    observer.start()

    print("\n" + "=" * 80)
    print("监控模式已启动 - 文件变化时将自动重建索引")
    print("输入问题开始问答(输入 'exit' 退出)")
    print("=" * 80)

    try:
        while True:
            query = input("\n📝 【用户】: ").strip()
            if query.lower() in ['exit', 'quit', '退出']:
                break

            if not query:
                continue

            response = rag.query_engine.query(query)
            print(f"\n💬 【回答】: {response}")

            # 显示来源
            print("\n📚 【来源】:")
            for node in response.source_nodes[:3]:
                file_name = node.metadata.get('file_name', '未知')
                print(f"  - {file_name} (相关度: {node.score:.4f})")

    finally:
        observer.stop()
        observer.join()


def main():
    """主函数:选择运行模式"""

    print("多文件RAG系统 - 选择运行模式:")
    print("1. 交互式问答")
    print("2. 批量处理")
    print("3. 监控模式(自动更新)")

    choice = input("\n请输入选择 (1/2/3): ").strip()

    if choice == "1":
        interactive_mode()
    elif choice == "2":
        batch_mode()
    elif choice == "3":
        watch_mode()
    else:
        print("无效选择,运行默认交互式模式")
        interactive_mode()


if __name__ == "__main__":
    main()

代码解析:

系统启动时,首先通过MultiFileRAGSystem类的构造函数完成基础配置。初始化过程包括设置文档目录路径、配置Ollama服务连接的嵌入模型(默认使用nomic-embed-text)和大语言模型(默认使用deepseek-r1:1.5b),以及定义文本处理参数如块大小512字符、块重叠50字符和检索返回数量top_k=5。同时,系统会检查文档目录是否存在,配置全局Settings对象,初始化文本切分器,并准备持久化存储目录。

系统通过scan_documents方法递归扫描指定目录下的所有文件,统计总文件数、支持的文件格式(包括txt、pdf、docx、pptx、xlsx、md、csv等十多种格式)、文件大小分布等信息,并识别不支持的文件格式。随后,load_documents方法调用SimpleDirectoryReader加载所有支持格式的文档,将每个文件转换为Document对象,保留文件名、文件大小等元数据信息。

在索引构建阶段,系统使用SentenceSplitter将加载的文档切分为固定大小的文本块(节点),每个节点包含文本内容和来源元数据。通过OllamaEmbedding将文本块转换为向量表示,并存储到Faiss向量数据库中构建VectorStoreIndex。如果启用了持久化功能,索引会被保存到磁盘,方便后续快速加载而无需重复构建。构建完成后,系统会统计生成的文本块数量和平均长度。

setup_query_engine方法负责配置完整的查询流水线。首先创建VectorIndexRetriever检索器,设置相似度检索的top_k参数。然后创建CompactAndRefine响应合成器,启用流式输出和详细模式。接着配置SimilarityPostprocessor后处理器,设置相似度阈值为0.6以过滤低质量结果。最后将这些组件组合成RetrieverQueryEngine,形成完整的查询处理链路。

当用户提交查询时,系统执行query_with_thinking方法,该方法设计为生成器函数以支持流式输出。查询处理分为多个阶段:

首先是检索阶段,系统将用户问题向量化后到索引中检索最相似的top_k个文本块,返回每个块的文本内容、相似度分数和来源文件信息,这些结果以"retrieval"类型的事件流式输出。

其次是提示词构建阶段,系统将检索到的文本块按照来源文件组织,构建包含完整上下文信息的提示词,明确标注每个知识片段的来源文件,要求LLM严格基于提供的知识回答,并可提及信息来源。构建的提示词以"prompt"类型事件输出。

然后是流式生成阶段,系统调用查询引擎执行查询,获取流式响应。在生成过程中,系统实时分析输出的文本流,智能区分"思考过程"和"最终答案"两部分。通过检测"思考:"、"分析:"等关键词标记思考过程的开始,检测"答案:"、"回答:"等关键词标记思考与回答的边界。思考过程的每个文本块以"thinking_chunk"类型输出,回答部分以"answer_chunk"类型输出,实现类似DeepSeek-R1的思考过程展示效果。

最后是结果整合阶段,生成完成后,系统收集完整的思考过程和最终答案,同时整理所有引用的源节点信息,包括每个节点的文本预览、相似度分数和来源文件,以"complete"类型事件返回完整结果。

三种运行模式详解
交互式模式是系统的主要使用方式,初始化RAG系统后进入问答循环。用户输入问题后,系统实时展示检索到的相关文本块及其相似度分数,然后逐字显示模型的思考过程,接着显示最终生成的答案,最后列出所有信息来源文件及具体引用的文本片段,实现完全透明的问答过程。

批量处理模式适用于需要处理多个预定义问题的场景。系统从questions.txt文件读取问题列表,或使用内置示例问题,依次处理每个问题。每个问题的回答结果和来源信息被收集并保存为结构化的JSON文件,便于后续分析和处理。

监控模式通过watchdog库实现文件系统的实时监控。当检测到文档目录中的文件被修改、创建或删除时,系统自动触发索引重建,确保知识库的实时性。同时,用户仍可进行问答交互,系统会使用最新的索引提供服务。为防止频繁重建,设置了60秒的重建冷却时间。

运行效果对比

直接在命令行中运行提问

>>> 安卓设备开机流程有哪些
Thinking...
好的,用户之前问过关于“helolo”的意思,我解释了一下,然后他又问了“你好”。看起来他是在确认是否能直接使用我的回答。现在他
询问“安卓设备开机流程有哪些”,这显然是在寻求指导。

我觉得可能有些疑问或者对安卓设备操作感兴趣。所以,我决定详细介绍一下安卓设备的开机步骤,包括启动程序、设置软件和完成一
些基本的用户操作。

可能他有新手,或者是想确认一些基本信息来更好地使用手机或电脑,比如设置、任务管理等。我的回答要尽量简单易懂,避免太专业
的术语,让他能够轻松理解。

另外,我还用了感叹号加强语气,显得比较热情和亲切,希望他能对我的建议有所认可。这样不仅帮助他解决了问题,还增加了与用户
的互动感。
...done thinking.

好的!安卓设备开机流程主要分为以下几步:

---

### **第一步:找到并启动操作系统**
1. 打开手机或电脑。
2. 在桌面上找到“应用商店”(在Windows系统中是“Settings”,在Mac系统中是“Programmer”)。
3. 点击“应用商店”后,找到并打开“Android”或“安卓”应用商店(根据你使用的设备类型而定)。

---

### **第二步:启动目标设备**
1. 打开“设置”(如“ Settings”在Windows系统中,或“System Settings”在Mac系统中)。
2. 在设置窗口中找到并打开你想操作的安卓设备的操作系统版本。
3. 点击“确定”即可开始运行目标设备。

---

### **第三步:进入设置**
1. 打开“设置”,然后选择要启动的设备。
2. 设备在设备界面上会显示为一个图标或图标,点击完成即可连接到目标设备。

---

### **第四步:运行设备**
1. 确认输入并点击“确定”按钮(Windows系统)或“下一步”(Mac系统)。
2. 运行目标设备后,你可以看到设备的设置界面,可以通过右键点击设备进行更多操作。

---

### **注意事项**
- 如果你的手机或电脑没有连接到互联网,可能需要关闭网络选项以确保设备能够正确启动。
- 检查并安装必要的软件,如系统更新、应用商店和第三方工具等。

希望这对你有帮助!如果你还有其他问题,随时告诉我哦! 😊

>>> Send a message (/? for help)

RAG运行效果

📝 【用户】: 安卓设备开机流程有哪些

🔄 【系统】正在处理...
--------------------------------------------------------------------------------

🔍 执行查询: '安卓设备开机流程有哪些'

📚 【检索结果】
  [1] 相关度: 0.6225 | 来源: 2022-12-12-【Android进阶】Android设备开机流程.md
      内容: 通常是芯片内部固化的一段非常小的启动代码(BootROM)。这段代码是芯片制造商预先写入的,不可修改

## 执行芯片内...
  [2] 相关度: 0.6050 | 来源: 2022-12-14-【Android进阶】Android热门原理流程总结.md
      内容: ## 内存泄漏常见场景
见性能优化篇:
[【Android性能优化】内存](./2025-1-4-【Android性能优...
  [3] 相关度: 0.6010 | 来源: 2022-12-12-【Android进阶】Android设备开机流程.md
      内容: ..

## 桌面环境启动阶段
System Server启动完成后,AMS会启动Launcher应用。Launcher...
  [4] 相关度: 0.5841 | 来源: 2022-12-14-【Android进阶】Android热门依赖库知识点总结.md
      内容: * ViewModel 暴露 LiveData
* View(Activity/Fragment) 观察 LiveDat...
  [5] 相关度: 0.5775 | 来源: 2022-12-14-【Android进阶】Android热门依赖库知识点总结.md
      内容: * 当 Activity 可能被销毁时(如配置变更或后台回收),系统会调用 onSaveInstanceState(Bu...

📝 【提示词预览】
  
    基于以下从多个文件中检索到的知识回答问题。每个知识片段都标注了来源文件。

    [来自文件: 2022-12-12-【Android进阶】Android设备开机流程.md]
通常是芯片内部固化的一段非常小的启动代码(BootROM)。这段代码是芯片制造商预先写入的,不可修改

## 执行芯片内部BootROM代码
BootROM代码执行基本的硬件初始化,验证并加载下一阶段的引导程序(通常是从特定存储区域),实现安全验证(如验证Bootloader签名)

## 安全启动(Secure Boot)验证
现代Android设备都支持安全启动机制,BootROM会验证Bootloade...

🧠 【模型思考中】
Android设备开机流程主要包括以下几个部分:

1. **系统服务和桌面组件初始化完成**:设备显示完整的桌面环境,用户可以开始与设备交互。

2. **系统启动稍作了解即可**:除了SystemUI和Launcher外,我们更多关注的是应用层的启动流程。在Android进阶的文章中详细描述了这些内容。

3. **系统启动流程**:
   - 系统启动流程包括冷启动流程、Handler消息机制以及Activity Window初始化等部分。
   - 冷启动流程用于快速启动设备,使其处于待机状态。
   - Handler消息机制负责处理用户请求和应用信息的同步。
   - Activity Window初始化为每个应用提供一个独立的工作界面。

4. **设备进入 Bootloader模式**:在系统运行时,设备会按特定键(如Volume+Power)进入Bootloader模式。在启动完成后,Boot ROM代码会被加载并执行,确保设备安全运行。

这些内容详细涵盖了Android设备开机流程的核心步骤和过程,从系统初始化到用户界面的设置。

📊 【信息来源】

  📄 2022-12-12-【Android进阶】Android设备开机流程.md:
    [1] 相关度: 0.6225
        通常是芯片内部固化的一段非常小的启动代码(BootROM)。这段代码是芯片制造商预先写入的,不可修改...
    [2] 相关度: 0.6010
        ..

## 桌面环境启动阶段
System Server启动完成后,AMS会启动Launcher应...

  📄 2022-12-14-【Android进阶】Android热门原理流程总结.md:
    [1] 相关度: 0.6050
        ## 内存泄漏常见场景
见性能优化篇:
[【Android性能优化】内存](./2025-1-4-【...

================================================================================

传统模型回答(左):

完全依赖模型预训练知识,回答内容泛化且存在明显错误

将安卓设备开机流程混淆为"打开应用商店"、"启动目标设备"等错误操作

出现了"在Mac系统中是Programmer"等荒谬描述

整体回答缺乏技术深度,更像是通用设备使用指南

RAG增强回答(右):

严格基于检索到的技术文档回答问题

准确描述了BootROM、安全启动、System Server、Launcher等专业概念

引用了具体的Markdown文档作为知识来源(如2022-12-12-【Android进阶】Android设备开机流程.md)

回答内容专业、准确,符合Android开发者的技术认知

结语

本文从RAG的出现原因出发,详细讲解了向量化流程、推理检索流程和输出整理优化,并最终给出了一个完整、可运行的本地RAG系统实现。通过这个实战项目,你可以清晰地看到:RAG的本质就是检索 + 上下文增强 + 生成这三步曲。

RAG系统的本质是”开卷考试”:当面对”安卓设备开机流程”这样的专业问题时,它不像传统模型那样凭记忆”闭卷作答”(然后编造错误答案),而是先查阅技术文档资料,再基于资料给出准确回答。这种模式既保留了大语言模型的强大生成能力,又通过外部知识库弥补了其知识局限性和幻觉问题,是构建专业领域AI助手的理想方案。

当你运行这个系统,输入”健康的水果推荐”,即使知识库中从未出现”推荐”二字,系统也能通过语义相似度找到苹果、香蕉、橙子的描述——这就是向量检索的魅力。当你看到大模型基于检索到的知识给出准确回答,而不是凭空编造时,你就理解了RAG为什么能成为AI应用开发的基石。

从简单的流水线到自主的Agentic RAG,这项技术仍在快速演进。但无论多复杂的系统,其核心都是本文揭示的这些基本原理。希望这篇文章能帮助你打下坚实的基础,在RAG的世界里走得更远。

【AI】OpenClaw介绍与部署实操

【AI】OpenClaw介绍与部署实操

本文介绍了最近比较热门的Agent领域的OpenClaw工具,其运行原理,部署实战

2026年2月14日 湖北

提笔写下这篇文章时,窗外的鞭炮声正在炸响。春节将近,我也终于能静下心来,回顾一下过去三个月的人生轨迹。

从去年11月至今,我一直在跳槽后的着陆期中全力奔跑。这是一段既紧张又充实的时光——所有的精力都投入到熟悉新公司的业务中,目标是快速上手,顺利转正。幸运的是,我如愿从车机开发转向了手机端,终于可以开发自己更喜爱、更灵活的产品。更难得的是,遇到的导师和领导都是很好的人,在他们的帮助下,年前我也是顺利转正。

这三个月里,我把全部精力都扑在业务上,以至于几乎切断了和外部技术世界的联系。而这段时间,恰恰也是AI领域风云变幻的三个月。

看着各家大模型的能力进化,作为一个纯软件开发者,我不禁开始思考:如果只专注于客户端的UI渲染、状态管理、性能优化,未来被AI Agent替代的概率有多高?那些曾经让我引以为傲的手艺,在能够自主操作电脑、调用API、生成代码的AI面前,还能保持多久的不可替代性?

我开始意识到,需要跳出”写代码”的舒适区,去提升自己的架构视野。我需要了解一个完整的产品工程是如何相互配合来达成设计功能的,需要搞清楚各个技术领域的边界和上限在哪里——无论是前端、后端,还是正在重塑一切的AI。

趁着春节这段难得的大块时间,我想把这三个月的课补上。了解行业新资讯,看看AI领域又迸发了哪些新的火花。而第一个进入视野的,就是这只正在席卷技术圈的”龙虾”——OpenClaw

OpenClaw是什么

如果你最近关注科技新闻,一定见过“OpenClaw”这个名字。它以一种近乎野蛮的方式,把世界分割成了两半:一半是狂热的“逮虾户”,让AI代替自己写代码、抢门票、整理邮件,甚至试图让它自己去“打工赚钱”;另一半则在尝鲜后感慨,这家伙虽然潜力巨大,但部署起来既烧脑又烧钱。

尽管如此,OpenClaw的效应仍在持续发酵。阿里云、腾讯云等大厂连夜上线一键部署方案,Mac mini 因其被卖到断货,甚至极客们已经开始在旧手机上魔改所谓的“ClawPhone”。为什么一个开源的AI项目能引发如此大的震动?它和过去的ChatGPT这类工具有什么本质区别?

今天,我们将深入拆解OpenClaw的前世今生,并手把手教你如何将它部署到你的MacBook上,让你亲手养一只属于自己的“数字龙虾”。

OpenClaw来自哪里

OpenClaw(曾用名:Clawdbot / Moltbot)是一个开源的个人AI助理项目,由开发者Peter Steinberger于2026年1月正式发布。

它的Logo是一只龙虾,口号是 “The AI that actually does things” (真正干活的AI)。但仅凭这些标签,还不足以解释它的火爆。它的革命性在于其运行逻辑:

ChatGPT 是一个漂浮在云端的超级大脑,它能说会道,但没有“手脚”;而 OpenClaw,直接住进了你的电脑里。

以往的AI助手往往局限于云端沙盒,无法触碰你的本地文件。而OpenClaw通过部署在本地硬件(如你的MacBook),获得了操作系统层面的权限。它可以读取你的硬盘、访问你的浏览器历史、调用你的终端,从而真正做到了从“只会说”到“直接干”的进化。

曲折的发展历程

OpenClaw的诞生并非一帆风顺,其命名史堪称一部开源项目的求生欲史:

  1. 起源(2025年底): 项目最初由奥地利程序员Peter Steinberger开发,彼时他刚出售了自己的PDF工具公司,全职投入编程。项目起初在博客中以“Clawd”的名字亮相。
  2. 正式发布(2026年1月5日): 项目在GitHub上正式定名为“Clawdbot”。凭借其极强的实用性,GitHub星标迅速飙升至18.6万以上。
  3. 首次更名(1月27日): 由于被Anthropic指控商标侵权,Clawdbot被迫更名为“Moltbot”(意为蜕皮)。开发者自嘲:“Same lobster soul, new shell”(同样的龙虾灵魂,换了一身新壳)。
  4. 最终定名(1月30日): 为了彻底解决商标问题,项目最终统一命名为“OpenClaw”,寓意“开源赋能,精准高效”,并一直沿用至今。

在这一系列动荡中,项目的核心代码和功能不仅没有受损,反而借助社区的关注度实现了爆发式增长,最终引爆了2026年的AI Agent浪潮。

核心功能与架构拆解

OpenClaw之所以强大,是因为它不仅仅是一个对话机器人,而是一个完整的自动化闭环系统。它的核心架构主要由四部分构成:

  1. Gateway(网关): 这是OpenClaw的心脏,一个始终在后台运行的守护进程。它负责连接各类聊天软件(如iMessage、Telegram、WhatsApp)并处理与外界的交互。
  2. Agent(智能体): 负责驱动“大脑”思考。它接入大语言模型(如Claude、GPT或阿里云百炼),处理上下文记忆与逻辑推理。
  3. Skills(技能): 这是OpenClaw的“手”。通过人工编写好的标准工作流(Workflow),AI可以执行网页调研、浏览器自动化、访问邮箱等具体操作,准确率远超纯模型调用。
  4. Memory(记忆): 这是OpenClaw的“长期记忆”。它会将对话内容和用户偏好以文件形式保存在本地文件夹中。下次你再问起某件事,它能回忆起你几周前随口提过的一个想法。

它能帮你做什么?

  • 基础办公: 生成工作总结、自动分类文件、定时发送邮件。
  • 跨工具协同: 从钉钉接收指令,自动调用浏览器检索信息,生成文档后再同步发送至邮箱。
  • 代码开发: 自然语言生成代码、排查错误、甚至自主开发新的“Skills”来扩展自己的能力。
  • 生活助手: 定时刷新抢票、监控商品价格、自动回复消息。

四、macOS本地部署教程

最激动人心的部分来了。下面我们将以 macOS 为例,通过官方推荐的一键安装脚本,让你在本地电脑上拥有自己的 AI 助理。

⚠️ 风险提示: OpenClaw 拥有极高的系统权限。为了防止它误删文件或因 BUG 导致系统卡死,强烈建议使用备用 Mac 电脑,或做好完整的数据备份。这也是为什么 Mac mini 最近卖断货的原因——大家需要一台物理隔离的“AI 肉身”。

第一步:安装前的准备

  1. 检查系统环境: OpenClaw 基于 Node.js 运行。打开你的终端(Terminal),输入以下命令检查 Node.js 版本:
    node -v
    

    如果版本低于 v22.0.0,或提示“command not found”,请先访问 Node.js 官网 下载并安装 LTS 版本。

  2. 准备 API Key: OpenClaw 本身不带大模型,需要接入外部大脑。你可以提前准备好 Anthropic Claude、OpenAI 或国内阿里云百炼、DeepSeek 等平台的 API Key。

第二步:快速安装 OpenClaw

在终端中执行以下命令(这是官方推荐的一键安装脚本,会自动检测系统并安装依赖):

curl -fsSL https://openclaw.bot/install.sh | bash

安装完成后,脚本通常会自动进入一个名为 onboard 的交互式设置向导。如果因为某些原因中断了向导,可以随时通过以下命令重新启动:

openclaw onboard --install-daemon

第三步:初始化配置(Onboard 向导)

onboard 向导中,你需要按提示完成几项核心配置:

  1. AI 模型配置: 选择你需要的模型提供商(如 Google Gemini、OpenAI 等),并粘贴你准备好的 API Key。如果不确定,可以先选择 Google 授权登录或选择默认配置。
  2. 渠道配置: 向导会询问你希望通过哪个聊天软件与 OpenClaw 交流(如 Telegram、WhatsApp)。对于首次在 Mac 上测试,建议先跳过,我们后续可以直接使用 Web 界面。
  3. 技能(Skills)与钩子(Hooks): 当问到 Install default skills? 时,强烈建议选 Yes(先按空格键选中,再按回车确认)。Hooks 也推荐安装,这会极大增强助理的功能性。

第四步:检查服务与启动 UI

配置完成后,我们来验证一下这只“龙虾”是否在正常工作。

  1. 运行健康检查:
    openclaw doctor
    

    这个命令会对你的系统环境和配置文件进行全面体检,并给出修复建议。

  2. 查看网关状态:
    openclaw status
    

    或者

    openclaw gateway status
    

    如果一切正常,你会看到 Gateway 服务正在运行(active)。

  3. 打开操作界面: 在确保 Gateway 启动的前提下,执行:
    openclaw dashboard
    

    该命令会自动生成一个包含临时登录令牌的 URL(通常是 http://127.0.0.1:18789/)并自动在浏览器中打开。

第五步:开始使用

在浏览器打开的 Dashboard 界面中,你可以直接与 AI 助理聊天了。试着让它帮你整理一下“下载”文件夹,或者让它总结一下你电脑里的某份项目文档。

小技巧: 如果你更喜欢终端环境,可以用 openclaw tui 命令启动全屏的文本交互界面,体验极客范儿的操作。

常见问题与解决

  • 问题:提示 openclaw: command not found 解决: 这通常是 npm 全局安装路径没加到系统 PATH 里。输入 npm prefix -g 查看路径,然后编辑你的 ~/.zshrc 文件,加入 export PATH="$(npm prefix -g)/bin:$PATH",重启终端即可。

  • 问题:安装时报错 sharp 模块错误 解决: macOS 有时会因 Homebrew 安装的 libvips 库冲突导致。尝试强制安装预编译二进制文件:

    SHARP_IGNORE_GLOBAL_LIBVIPS=1 npm install -g openclaw@latest
    

结语

OpenClaw的火爆,本质上反映了人们对 AI 落地的渴望——我们不仅想要一个能聊天的机器人,更想要一个能干活的数字员工。它像一条鲶鱼,搅动了硬件厂商、云厂商和开发者社区的神经。

通过今天的教程,你的 Mac 应该已经成功“养”上了一只龙虾。但这只是开始。OpenClaw 的真正魅力在于你可以教它新的技能,甚至让它自己编写代码来改进自己。未来已来,只是分布不均。现在,它就在你的终端里。

Happy prompting! 🦞

【AI】集成doubao-vision视觉api实现卡路里识别

【AI】集成doubao-vision视觉api实现卡路里识别

本文介绍了Android和IOS平台集成在线视觉AI的过程

这个功能实现很早了,在开源项目 PeachAssistant 里已经有体现了,利用CMP跨平台技术在Android和IOS平台均完成了功能的集成。现记录一下api的介绍和两个平台的执行流程。

豆包API介绍

首先在个人控制台的服务管理页面开通视觉识别api权限,获取 API_KEY ,baseURL为 https://ark.cn-beijing.volces.com/api/v3 。图片可以使用url或者文件文件base64编码上传两种方案。

如果你要传入的图片/视频在本地,你可以将这个其转化为 Base64 编码,然后提交给大模型。下面是一个简单的示例代码。

传入 Base64 编码格式时,请遵循以下规则

传入的是图片:
格式遵循data:image/<图片格式>;base64,<Base64编码>,其中,
图片格式:jpeg、png、gif等,支持的图片格式详细见图片格式说明。
Base64 编码:图片的 Base64 编码。

传入的是视频:
格式遵循data:video/<视频格式>;base64,<Base64编码>,其中,
视频格式:MP4、AVI等,支持的视频格式详细见视频格式说明。
Base64 编码:视频的 Base64 编码。

请求实例:

BASE64_IMAGE=$(base64 < path_to_your_image.jpeg) && curl https://ark.cn-beijing.volces.com/api/v3/chat/completions \
   -H "Content-Type: application/json"  \
   -H "Authorization: Bearer $ARK_API_KEY"  \
   -d @- <<EOF
   {
    "model": "doubao-seed-1-6-251015",
    "messages": [
      {
        "role": "user",
        "content": [
            {
            "type": "image_url",
            "image_url": {
              "url": "data:image/jpeg;base64,$BASE64_IMAGE"
            },
            {
            "type": "text",
            "text": "图里有什么"
            }
        ]
      }
    ],
    "max_tokens": 300
    }
EOF

可以通过 detail 字段控制图片理解的精细度。

  • low:“低分辨率”模式,默认此模式,处理速度会提高,适合图片本身细节较少或者只需要模型理解图片大致信息或者对速度有要求的场景。此时 min_pixels 取值3136、max_pixels 取值1048576,超出此像素范围且小于3600w px的图片(超出3600w px 会直接报错)将会等比例缩放至范围内。
  • high:“高分辨率”模式,这代表模型会理解图片更多的细节,但是处理图片速度会降低,适合需要模型理解图像细节,图像细节丰富,需要关注图片细节的场景。此时 min_pixels 取值3136、max_pixels 取值4014080,超出此像素范围且小于3600w px的图片(超出3600w px 会直接报错)的图片将会等比例缩放至范围内。

例如:

curl https://ark.cn-beijing.volces.com/api/v3/chat/completions \
   -H "Content-Type: application/json" \
   -H "Authorization: Bearer $ARK_API_KEY" \
   -d '{
    "model": "doubao-seed-1-6-251015",
    "messages": [
        {
            "role": "user",
            "content": [                
                {"type": "image_url","image_url": {"url":  "https://ark-project.tos-cn-beijing.volces.com/doc_image/ark_demo_img_1.png"},"detail": "high"},
                {"type": "text", "text": "支持输入图片的模型系列是哪个?"}
            ]
        }
    ],
    "max_tokens": 300
  }'

KMP公共网络请求

class DoubaoVisionRepository(private val ktorClient: KtorClient) {

    companion object {
        const val BASE_URL =
            "https://ark.cn-beijing.volces.com/api/v3"
        const val VISION_SYSTEM_PROMT =
            "下图是一张食物图片,请你计算每种食物的重量和卡路里,返回一个json,其中name为String,weight为Int,calorie为Int(单位千卡),json格式:\n" +
                    "{\n" +
                    "  \"foods\": [\n" +
                    "    {\n" +
                    "      \"name\": \"食物名称\",\n" +
                    "      \"weight\": \"食物重量\",\n" +
                    "      \"calorie\": \"食物卡路里\"\n" +
                    "    }\n" +
                    "  ]\n" +
                    "}"
        const val API_KEY = "xxxxxxxxxxxx"
        const val MODEL_NAME = "doubao-1-5-vision-pro-32k-250115"
    }

    suspend fun calCalorieByAI(imageType: String, imageBase64:String) = withContext(Dispatchers.IO) {
        ktorClient.client.post("${BASE_URL}/chat/completions") {
            // 配置请求头
            headers {
                append("Content-Type", "application/json")
                append("Authorization", "Bearer $API_KEY")
            }
            setBody(
                DoubaoVisionRequest(
                    model = MODEL_NAME,
                    messages = listOf(
                        DoubaoRequestMessage(
                            role = ChatRole.SYSTEM.roleDescription,
                            content = listOf(
                                DoubaoVisionContent(
                                    type = "text",
                                    text = VISION_SYSTEM_PROMT,
                                ),
                                DoubaoVisionContent(
                                    type = "image_url",
                                    image_url = ImageUrl(
                                        url = "data:image/$imageType;base64,$imageBase64"
                                    ),
                                )
                            )
                        ),
                    )
                )
            )
        }.body<DoubaoVisionResponse>()
    }
}

Android实现

权限申请

相册上传

运行截图:

实时拍照上传

IOS实现

权限申请

相册上传

实时拍照上传

【AI】多模态模型的多样化数据处理

【AI】多模态模型的多样化数据处理

本文介绍了文本模型之外的多模态AI模型如何处理数据的

经过前面若干篇的学习,我了解到LLM是如何处理输入文本,一轮一轮地进行前向推理,最后输出结果反馈的。

那多模态的AI模型,又是如何处理一帧一帧的图像,或者音频数据呢?现对这些不同于文本的数据处理进行一段学习总结。

简单来说,模型通过专门设计的 “编码器” 将不同类型的数据“翻译”成同一种“语言”——也就是 向量 。这个过程可以分为两大步:

  1. 独立编码(Independent Encoding):每种数据类型(图像、音频)都有一个专门的编码转换器,负责将其从原始格式转换成一个初步的向量序列。
  2. 对齐与融合(Alignment & Fusion):通过特殊的训练方法,让这些来自不同专家的向量在同一个“语义空间”里对齐,使得“小狗的图片”和“小狗的叫声”以及文字“小狗”的向量在空间中的位置非常接近。

向量嵌入模型

向量嵌入(Vector Embedding) 模型是当今许多AI应用的基石。

想象一下,你有一个巨大的图书馆,里面有成千上万本书。现在,你想找到所有和“科幻”相关的书。一个笨方法是逐一阅读每一本书的简介。这太慢了。

一个聪明的图书管理员(我们的AI模型)想出了一个好办法:他没有给书贴上“科幻”、“历史”这样的 简单标签 ,而是为每本书在图书馆里分配了一个 精确的三维坐标 (例如,坐标 [x, y, z])。

这个坐标的分配原则是:

  • 内容相似的书,在空间中的位置就非常接近。比如,《三体》和《银河帝国》的坐标可能非常靠近。
  • 内容无关的书,在空间中的位置就非常遥远。比如,《三体》和《莎士比亚戏剧集》的坐标会离得很远。
  • 坐标轴本身也代表了某种“意义”。也许x轴代表“虚构程度”,y轴代表“科技含量”,z轴代表“年代”。

这样一来,找书就变得非常简单:

  1. 你告诉管理员你要找“一部关于星际旅行和外星文明的小说”。
  2. 管理员将你的需求也转换成一个坐标。
  3. 然后,他在图书馆的这个三维空间里,找到离你的需求坐标 最近 的那些书。

在这个比喻里:

  • 书/你的需求:就是我们要处理的数据(单词、句子、图片、商品等)。
  • 坐标 [x, y, z]:就是向量嵌入 (Vector Embedding)。它是一个由数字组成的数组(向量),代表了原始数据在高维空间中的位置。
  • 整个三维空间:被称为嵌入空间 (Embedding Space)
  • 聪明的图书管理员:就是向量嵌入模型

向量嵌入可以将各种复杂、离散的数据(如文字、图片)转换成计算机可以理解和比较的、连续的、稠密的数字向量,并在这个过程中保留数据的“语义信息”。

为什么需要向量嵌入?

计算机不理解“苹果”这个词。它只懂数字。在AI出现之前,我们可能会用 One-Hot 编码(独热编码) 来表示单词。

假设我们的词典里只有5个词:[猫, 狗, 苹果, 香蕉, 橙子]。

  • 猫:[1, 0, 0, 0, 0]
  • 狗:[0, 1, 0, 0, 0]
  • 苹果:[0, 0, 1, 0, 0]

这种方法有两个 严重的缺陷

  1. 维度灾难:如果词典有10万个词,每个词的向量就有10万维,非常稀疏和浪费空间。
  2. 无法表达语义相似性:从数学上看,[1, 0, 0][0, 1, 0] 之间的距离,与 [1, 0, 0][0, 0, 1] 之间的距离是完全一样的。也就是说,模型无法知道“猫”和“狗”的关系比“猫”和“苹果”更近。所有词之间都是孤立的。

向量嵌入完美地解决了这两个问题。它使用一个更低维度(通常是几百到几千维)稠密向量 来表示数据,并且向量之间的距离和方向能够反映数据之间的语义关系。

向量嵌入模型的工作原理

模型是如何学会给每个单词或句子分配一个“有意义”的坐标的呢?答案是:通过在一个巨大的数据集上进行 “自监督学习”

核心原理可以用一句话概括:“一个词的意义,由它周围的词来定义”

我们以一个经典的词嵌入模型 Word2Vec 为例来解释这个过程。

训练过程(以 Word2Vec 的 Skip-gram 模式为例):

  1. 准备数据:获取海量文本,比如整个维基百科。

  2. 建立任务:我们给模型设定一个任务——根据一个中心词,预测它周围的词(上下文)
    • 例如,在句子 “一只可爱的正坐在垫子上” 中。
    • 中心词是 “猫”。
    • 上下文是 “一只”、“可爱的”、“正”、“坐在”。
  3. 模型初始化
    • 为词典里的每一个词,随机生成一个向量(比如300维)。此时,这些向量是毫无意义的。
  4. 开始训练(迭代学习)
    • 输入:我们把 “猫” 的随机向量输入到一个简单的神经网络中。
    • 预测:模型会根据这个输入向量,输出一个预测,表示它认为“猫”周围最可能出现哪些词。在训练初期,这个预测肯定是乱七八糟的。
    • 计算误差:我们将模型的预测结果与真实的上下文(“一只”、“可爱的”等)进行比较,计算出一个损失(Loss)误差(Error)。误差越大,说明模型预测得越差。
    • 反向传播与更新:算法会根据这个误差,微调(更新)神经网络的权重,尤其是“猫”的输入向量。调整的原则是:让“猫”的向量变得“更擅长”预测出它周围的词
    • 重复:对文本库里的每一个词都重复这个过程亿万次。
  5. 最终结果
    • 训练结束后,词典里每个词的向量都经过了无数次的微调。
    • 因为“猫”和“狗”经常出现在相似的上下文中(比如“可爱的__”、“喂养__”、“宠物__”),为了能同时预测好这些上下文,模型会“自发地”将“猫”和“狗”的向量调整到嵌入空间中非常相近的位置。
    • 而“猫”和“苹果”的上下文几乎完全不同,所以它们的向量在空间中就会相距很远。

最终,我们扔掉用于预测的神经网络,只保留训练好的、包含所有词及其对应向量的那个查找表。这个表就是我们的词嵌入模型

嵌入的奇妙特性:

训练好的嵌入向量甚至可以捕捉到更复杂的关系,最经典的例子是: \[\text{vector('King')} - \text{vector('Man')} + \text{vector('Woman')} \approx \text{vector('Queen')}\]

这表明,嵌入空间中的向量方向也蕴含了语义,例如“性别”或“皇室”等抽象概念。

著名/主流的嵌入模型

  1. Word2Vec (Google): 开创性的词嵌入模型,简单高效。它包含两种模式:Skip-gram(根据中心词预测上下文)和 CBOW(根据上下文预测中心词)。
  2. GloVe (Stanford): 另一种经典的词嵌入模型,它利用全局词-词共现矩阵来生成嵌入,考虑了全局统计信息。
  3. BERT (Google) & Transformer-based Models: 这是现代嵌入模型的主流。
    • 关键区别:Word2Vec 为每个词生成的向量是静态的、唯一的。但在现实中,词的意义随语境而变。例如,“bank”在 “river bank”(河岸)和 “investment bank”(投资银行)中的意思完全不同。
    • BERT这类模型是上下文相关的(Contextual)。它在生成一个词的嵌入时,会同时考虑整个句子的信息。因此,同一个词在不同句子中会得到不同的嵌入向量,这极大地提升了表示的准确性。
  4. OpenAI Embeddings (如 text-embedding-ada-002): 目前非常流行和强大的通用文本嵌入模型,广泛用于各种AI应用。
  5. CLIP (OpenAI): 一种强大的多模态嵌入模型。它可以为一张图片和一个描述该图片的句子生成非常相似的向量。这使得通过文本搜索图片成为可能。

应用场景

向量嵌入是许多现代AI系统的“引擎”,它的应用无处不在:

  1. 语义搜索/向量搜索
    • 传统的关键字搜索只能匹配字面内容。而向量搜索可以理解查询的“意图”。你搜索“夏天穿的透气鞋子”,它能返回商品名里没有这些词但符合描述的“网面运动凉鞋”。
    • 这是目前 RAG (Retrieval-Augmented Generation,检索增强生成) 技术的核心,大语言模型通过向量搜索找到相关知识库内容,再进行回答,以减少幻觉。
  2. 推荐系统
    • 将用户和商品都嵌入到同一个向量空间中。一个用户的向量,会和他可能喜欢的商品的向量非常接近。通过计算向量相似度,可以为用户推荐他可能感兴趣的商品、电影或音乐。
  3. 文本分类与聚类
    • 将文本转换成向量后,可以轻松地使用机器学习算法进行情感分析(正面/负面评论)、新闻主题分类等。相似的文本向量会自然地“聚”在一起。
  4. 问答系统和聊天机器人
    • 将用户的问题和知识库中的“问题-答案”对都转换成向量。通过找到与用户问题向量最相似的问题向量,来返回对应的答案。
  5. 图像搜索
    • 以图搜图(找到相似图片)或以文搜图(输入“一只猫在草地上”,返回对应的图片)。

图像数据

第一步类似于文本模型,首先要理解输入内容物是什么东西。在将图片信息与其他模态(如文本)进行融合之前,模型需要将原始像素数据转换为有意义的、可供计算的向量表示,这称为特征提取。

数据特征提取

一般通过 卷积神经网络 (CNN),尤其是像 ResNet、VGG 或 ViT (Vision Transformer) 这样的模型架构。

  • CNN 的作用: CNN 通过多层卷积操作,从图片中自动学习和提取层级特征。浅层提取边缘、纹理等基础特征;深层提取鼻子、眼睛、汽车等高层语义特征。
  • Vision Transformer (ViT) 的作用: ViT 不使用卷积,而是将图像分割成许多小块,然后使用 Transformer 的自注意力机制来捕捉这些小块之间的关系,这与处理文本的方式相似,有助于模态间的对齐。

提取器最终输出一个图像嵌入向量,它是一个高维向量,浓缩了整张图片或图片中关键区域的语义信息。

一、 图像数据的向量化

原始的图像数据是一个由像素值(RGB)构成的三维矩阵(宽 x 高 x 通道)。使用当前最主流的 Vision Transformer 架构来处理它时,这个流程是怎样的呢?

ViT过程拆解:

  1. 图像分块
    • 模型不会一次性看整个图像的几百万个像素,这计算量太大了。相反,它会像切拼图一样,将原始图像(例如 224x224 像素)切割成一系列固定大小的小方块(Patches),比如每个方块是 16x16 像素。
    • 这样,一张 224x224 的图像就变成了一个由 (224/16) * (224/16) = 14 * 14 = 196 个小方块组成的序列
  2. 展平与线性投射
    • 将每个 16x16x3 (3是RGB通道) 的小方块展平,变成一个长向量。
    • 然后,通过一个可学习的线性投射层(Linear Projection Layer),将这个长向量映射(降维或升维)到一个固定的维度,比如768维。
    • 现在,我们就得到了一个由196个768维向量组成的序列。这在结构上就和经过词嵌入的句子(由多个词向量组成的序列)非常相似了!
  3. 加入位置编码
    • 和文本一样,这些图像块的相对位置非常重要(“耳朵”在“头”的上面)。因此,模型会为每个图像块向量加入一个位置编码向量,来告诉模型每个小块的原始位置信息。
  4. 通过 Transformer 编码器
    • 将这个带有位置信息的向量序列输入到一个标准的 Transformer 编码器中。
    • 编码器内部的 自注意力机制(Self-Attention) 会让每个图像块去“关注”其他的图像块,从而理解它们之间的关系和全局结构。例如,一个代表“车轮”的图像块会和代表“车身”的图像块建立强关联。
    • 经过多层Transformer Block的处理后,模型就得到了对整个图像内容和结构的深度理解。
  5. 输出最终向量
    • 通常会借鉴BERT中的 [CLS] 思想,在图像块序列的最前面添加一个特殊的 [CLASS] 向量。在经过Transformer编码器后,这个 [CLASS] 向量对应的最终输出向量,就被认为是代表整个图像语义的聚合向量。

最终,一张复杂的图像就被转换成了一个单一的、高维的、包含丰富语义的向量(例如768维)。

音频数据

原始的音频数据是 一维的波形信号 ,它记录了随时间变化的振幅。直接处理这个长序列非常困难。因此,标准做法是先将其转换成一种“像图像一样”的二维表示。

预处理:波形转频谱图

音频的核心信息在于不同频率的声音随时间如何变化。通过 短时傅里叶变换(STFT) 将原始的一维波形转换成一个 频谱图(Spectrogram)

这个频谱图是一个二维图像:

  • X轴 代表 时间
  • Y轴 代表 频率
  • 颜色/亮度 代表该频率在该时间的 能量(音量) 通常会使用梅尔频谱图(Mel-Spectrogram),因为它更贴近人耳对频率的感知方式。通过这个转换之后,音频数据就变成了一张“图像”!

使用类似图像的处理方法

一旦我们有了频谱图这个二维表示,接下来的处理就和上面图像处理的流程非常相似了。模型(例如 Audio Spectrogram Transformer, AST)也会将这张频谱图切割成一系列的小方块(Patches)。同样地,对这些方块进行 线性投射 、加入 位置编码 ,然后将它们组成的序列送入一个 Transformer 编码器 。Transformer的自注意力机制能够捕捉音频序列中长距离的依赖关系,类似于理解一句话中前后词语的语境。

输出最终向量

与ViT类似,经过Transformer编码器处理后,模型会输出一个代表整个音频片段语义的聚合向量。这个向量捕捉了音频中的内容,比如是人声(说了什么)、音乐(什么风格)还是环境音(狗叫、汽车声)。

图像和音频的模态对齐融合

现在我们有了 图像向量、音频向量和文本向量 。但此时它们还处在各自的世界维度里,无法直接比较。让它们统一到同一个语义空间的关键技术是 对比学习 。这是多模态理解的核心。模型需要学会这些音频向量和文本/视觉向量之间的关系。

CLIP (Contrastive Language-Image Pre-training) 模型为例,它就是专门用来对齐图像和文本的:

  • 数据输入 :收集数亿个 (图像, 文本描述) 的配对数据。
  • 训练目标 :在训练过程中,模型会看到大量的“音频-文本” 键值对,例如: “一段狗叫声” 和文本 “一只狗在叫” 。将一个图像和它 正确匹配 的文本描述分别通过各自的编码器,得到 image_vectortext_vector。模型的目标是 拉近(Maximize Similarity) 这对正样本(matched pair)向量的相似度(例如,余弦相似度)。同时,对于一个图像, batch里的所有其他文本描述都是负样本(unmatched pairs)。模型的目标是 推远(Minimize Similarity) 这个图像向量和所有这些错误文本向量的相似度。

最终,“狗叫”的音频向量和“狗叫”的文本向量在语义空间中的位置会非常接近。通过在这种“连连看”式的任务上进行大规模训练,图像编码器和文本编码器会“被迫”学会一种共识。它们会自发地将 语义上相似 的概念映射到向量空间中的 邻近区域 ,无论这个概念是来自图片还是文字。

输入一张 “金毛犬在草地上玩耍” 的图片所生成的向量,会和句子 “a golden retriever playing on the grass” 生成的向量在空间上非常非常接近。

这个对齐过程同样适用于音频。通过训练 (音频, 文本描述) 配对数据,音频编码器也能学会将“狗叫声”的音频片段映射到和文字“dog barking”相近的空间位置。

【AI】端侧模型部署LiteRT篇

【AI】端侧模型部署LiteRT篇

本文介绍了借助Google的LiteRT框架在Android平台上运行端侧AI模型的流程

LiteRT简介

TensorFlow Lite 是 TensorFlow 生态系统中的一个重要组成部分,它是专门为在资源受限的设备(如手机、物联网设备、嵌入式系统和微控制器)上高效运行机器学习模型而设计的。你可以理解为,它是 TensorFlow 模型的“压缩和优化版本”的运行时环境,让 AI 能够 “走出云端,进入设备”

llama.cpp 一样,TensorFlow Lite也是通过 模型优化和压缩 来实现有限资源时高效运行的。

将模型的参数(如权重和激活值)从浮点数转换为更小的整数类型(如 8 位整数),大大减少模型大小和内存占用,同时提高推理速度。移除模型中不重要的连接和参数。将多个操作合并为一个,减少计算开销。TFLite 解释器本身非常小巧,可以在内存和存储空间有限的设备上运行。

TensorFlowLite在24年9月已经更名为LiteRT。 之所以更名,是因为 TensorFlow Lite 在发展过程中已经超越了最初仅支持 TensorFlow 模型的范畴。它现在能够高效支持从 PyTorch、JAX 和 Keras 等其他主流机器学习框架导出的模型。为了更好地体现这种“多框架”的愿景,并强调其作为设备端高性能运行时的通用性,Google 决定将其更名为 LiteRT。

LiteRT 的核心价值在于提供一个 快速小巧高效 的运行时环境,使训练好的机器学习模型能够在设备上本地执行推理。主要特性:

  • 针对设备端机器学习进行了优化:LiteRT 解决了五项关键的 ODML 约束条件:延迟时间(无需往返服务器)、隐私性(没有个人数据离开设备)、连接性(无需连接到互联网)、大小(缩减了模型和二进制文件大小)和功耗(高效推理和缺少网络连接)。
  • 支持多平台:与 Android 和 iOS 设备、嵌入式 Linux 和微控制器兼容。
  • 多框架模型选项:AI Edge 提供了一些工具,可将 TensorFlow、PyTorch 和 JAX 模型转换为 FlatBuffers 格式 (.tflite),让您能够在 LiteRT 上使用各种先进的模型。您还可以使用可处理量化和元数据的模型优化工具。
  • 支持多种语言:包括适用于 Java/Kotlin、Swift、Objective-C、C++ 和 Python 的 SDK。
  • 高性能:通过 GPU 和 iOS Core ML 等专用代理实现硬件加速。

LiteRT 将模型打包成一种名为 FlatBuffers 的高效可移植格式,文件扩展名为 .tflite

LiteRT 的部署运行流程

1. 模型加载

第一步,将 .tflite 文件(包含模型的执行图、权重和偏差)加载到内存中。

LiteRT 模型采用 FlatBuffers 格式,这种格式允许直接映射到内存,避免了传统序列化/反序列化所需的额外解析和内存复制,从而加快加载速度。

2. 构建解释器 (Interpreter)

LiteRT Interpreter 解释器是执行模型的核心组件。它使用静态图排序自定义(非动态)内存分配器

在内存分配方面, Interpreter 在运行时根据模型图预先分配好所需的张量内存,避免了推理过程中的动态内存分配开销,确保推理延迟稳定且较低。

3. 设置硬件加速器(Delegate/委派)

这一步是 LiteRT 实现高性能的关键。LiteRT 引入了 Delegate(委派) 机制,这是一个 API 接口,用于将模型的部分或全部操作卸载到设备上的特定 硬件加速器 上执行,而不是仅仅依赖 CPU。

常见的加速器:

  • GPU:通过 MLDrift(新的 GPU 加速实现)或旧的 GPU Delegate 进行加速。
  • NPU/DSP:通过 NNAPI(Android 上的神经网络 API)或高通、联发科等供应商特定的 SDK 来利用神经处理单元。
  • Edge TPU:Google 专用的边缘张量处理单元。
  • XNNPack:一个高度优化的 CPU 浮点运算库。

Delegate 会检查模型中的哪些操作可以在加速器上运行,并将它们打包交给加速器执行。

4. 数据预处理与运行推理 (Invoke)

数据预处理截断会将输入数据(如图像、音频)转换为模型期望的格式和维度。通过调用 Interpreter::Invoke()(或 LiteRT Next 中的 CompiledModel::Run())方法,LiteRT 解释器执行模型图中的操作。如果设置了 Delegate,相应的操作将在硬件加速器上执行。

5. 解释输出

获取输出张量的值,并将其转换为对应用有意义的结果(例如,将概率列表映射到具体的类别标签,或绘制目标检测的边界框)。

支持的 AI 模型

LiteRT 主要用于推理(Inference),它可以运行各种类型的经过优化的机器学习模型,特别是那些针对视觉音频自然语言处理 (NLP) 任务的模型:

模型类型常见应用优化特点
计算机视觉 (CV)图像分类、目标检测、图像分割、姿态估计、面部识别。CNN (如 MobileNet, EfficientNet) 经过量化和剪枝,利用 GPU 和 NPU 加速。
自然语言处理 (NLP)文本分类、命名实体识别、问答系统、小规模 LLM (轻量级大语言模型) 推理。Transformer 模型(如 BERT 变体)经过优化,注重模型大小和低延迟。
音频处理语音识别、关键词唤醒、声纹识别、环境声分类。各种序列模型和特定设计的声学模型。
通用机器学习分类、回归、时间序列预测。各种通用的 ML 模型,通常通过 TensorFlowPyTorch 等框架训练后转换为 .tflite 格式。

应用层的集成,在 Google 自己的开源项目中有已经体现:

google-ai-edge gallery

该应用也同步上架了Play Store,项目截图:

进入首页可以看到主要有四种使用主题,分别是图片分析,音频描述,提示词试验,以及AI模型对话。选择其中一个主题进入之后,通过 Chrome 浏览器授权 Hugging face 账号,就可以在 Gallery 中直接下载模型到其英应用到内部存储中。下图是音频识别到效果,做语言翻译,物种识别效果还不错,歌曲识别准确率不高。

Image 1 Image 1 Image 2

初始化读取到内存

应用最开始的步骤其实是模型文件检查,以及获取 Token 认证 Hugginface 社区去下载模型,这一步分析省略,因为各家的部署肯定是放置于自己的服务器上,或者直接存在assets文件夹岁随应用打包。

第一步看整个应用模型加载到内存的关键节点,模型初始化流程,用户在UI界面选择一个模型后, ModelManagerViewModel.initializeModel() 方法被调用,会开始检查模型是否已经初始化或正在初始化,避免重复操作。测试中,我加载一个自己下载导入的 gemma3-1b-it-int4 模型,可以看到如下信息:

initializeModel: Model(
name=gemma3-1b-it-int4.litertlm, 
displayName=, 
info=, 
configs=[com.google.ai.edge.gallery.data.LabelConfig@4061738, com.google.ai.edge.gallery.data.NumberSliderConfig@efb2311, com.google.ai.edge.gallery.data.NumberSliderConfig@5c34c76, com.google.ai.edge.gallery.data.NumberSliderConfig@eb7b277, com.google.ai.edge.gallery.data.SegmentedButtonConfig@ba16ee4], 
learnMoreUrl=, 
bestForTaskIds=[], 
minDeviceMemoryInGb=null, 
url=, 
sizeInBytes=584417280, 
downloadFileName=__imports/gemma3-1b-it-int4.litertlm, 
version=_, 
extraDataFiles=[], 
localFileRelativeDirPathOverride=, 
localModelFilePathOverride=, 
showRunAgainButton=false, 
showBenchmarkButton=false, 
isZip=false, 
unzipDir=, 
llmPromptTemplates=[], 
llmSupportImage=false, 
llmSupportAudio=false, 
imported=true, 
normalizedName=gemma3_1b_it_int4_litertlm, 
instance=null, 
initializing=false, 
cleanUpAfterInit=false, 
configValues={Max tokens=1024, TopK=40.0, TopP=0.9, Temperature=1.0, Choose accelerator=CPU},
totalBytes=584417280, 
acessToken=null)

在各种初始化和清除资源检查完成之后,会调用到 LlmChatModelHelper.initialize() 方法。创建 EngineConfig 对象,配置模型路径、后端(CPU/GPU)、最大token数等参数。使用配置创建 Engine 实例并初始化,通过 engine.createConversation() 创建对话实例,将引擎和对话实例包装在 LlmModelInstance 对象中,并赋值给 Model.instance 属性。

关键代码实现:

// LlmChatModelHelper.kt
val engineConfig = EngineConfig(
  modelPath = model.getPath(context = context),
  backend = preferredBackend,
  visionBackend = if (shouldEnableImage) Backend.GPU else null,
  audioBackend = if (shouldEnableAudio) Backend.CPU else null,
  maxNumTokens = maxTokens,
  enableBenchmark = true,
)

val engine = Engine(engineConfig)
engine.initialize()

val conversation = engine.createConversation(ConversationConfig(...))
model.instance = LlmModelInstance(engine = engine, conversation = conversation) 

这一步初始化完成之后,就有了和模型进行对话的环境,本地AI模型可以接收输入和进行推理。

输入和输出缓存处理

输入处理

Gemma-3n-E2BGemma-3n-E4B 具有多模态输入支持,可以接收文本、图像、音频等多种输入类型。在 LlmChatModelHelper.runInference() 方法中处理不同类型的输入。

如果用户输入时,有带上图像和音频类型的文件,会将图像和音频转换为字节数组:

val contents = mutableListOf<Content>()
for (image in images) {
  contents.add(Content.ImageBytes(image.toPngByteArray()))
}
for (audioClip in audioClips) {
  contents.add(Content.AudioBytes(audioClip))
}
// add the text after image and audio for the accurate last token
if (input.trim().isNotEmpty()) {
  contents.add(Content.Text(input))
}

所有内容被封装在 Content 对象列表中。再使用 conversation.sendMessageAsync() 异步发送消息,并且提供回调接口处理推理过程中的事件。

conversation.sendMessageAsync(
Message.of(contents),
object : MessageCallbacks {
    override fun onMessage(message: Message) {
        message.contents.filterIsInstance<Content.Text>().forEach {
            resultListener(it.text, false)
        }
    }

    override fun onDone() {
        resultListener("", true)
    }

    override fun onError(throwable: Throwable) {
        if (throwable is CancellationException) {
            Log.i(TAG, "The inference is canncelled.")
            resultListener("", true)
        } else {
            Log.e(TAG, "onError", throwable)
            resultListener("Error: ${throwable.message}", true)
        }
    }
},
)
输出处理流程

在上面触发消息发送那里,可以看到是通过 MessageCallbacks 接口处理输出:

  • onMessage() 回调处理中间结果,支持流式输出
  • onDone() 回调标记推理完成
  • onError() 回调处理错误情况

内部已经自动处理完了从向量到tokenid再查词汇表的对应过程,直接返回的string结果。

模型生命周期管理

如果是建立新对话,就调用的对话重置方法 LlmChatModelHelper.resetConversation() ,会停止并关闭当前对话并创建新的对话实例,保持引擎实例不变,仅重新创建对话对象。

如果是要退出页面,这时候就需要执行资源释放步骤。

LlmChatModelHelper.cleanUp() 方法负责清理模型资源,关闭本轮对话和引擎,将持有的模型实例置空。

fun cleanUp(model: Model, onDone: () -> Unit) {
    if (model.instance == null) {
        return
    }

    val instance = model.instance as LlmModelInstance

    try {
        instance.conversation.close()
    } catch (e: Exception) {
        Log.e(TAG, "Failed to close the LLM Inference conversation: ${e.message}")
    }

    try {
        instance.engine.close()
    } catch (e: Exception) {
        Log.e(TAG, "Failed to close the LLM Inference engine: ${e.message}")
    }

    val onCleanUp = cleanUpListeners.remove(model.name)
    if (onCleanUp != null) {
        onCleanUp()
    }
    model.instance = null

    onDone()
    Log.d(TAG, "Clean up done.")
}

退出对话重新进入的内存表现:

整体使用时的占用和 llama.cpp 持平,主要区别就是分了两段加载,在刚进入对话 loadModel() 时,没有加载全部权重数据到内存,在推理真正调用再加载的。

这条有别于llama.cpp的路线,在集成gguf模型时,主要的权重张量全部读取到了Native堆中,另外可以看到 Others 是占用最多的一块内存,核心原因可能是硬件加速与内存委托,端侧模型很可能在推理开始时,将计算任务委托给了专用的硬件加速器,例如 GPU、DSP 或 NPU,并通过 Android 神经网络 API (NNAPI) 或厂商特定的 SDK 实现。

在推理开始之前,模型权重、输入/输出张量等可能已经被加载到内存中,并通过标准的 C/C++ malloc/new 在 Native Heap 上分配。当推理开始并决定使用硬件加速时,模型运行时(例如 TensorFlow Lite Runtime)会 释放 Native Heap 上的一些大型张量(特别是那些将在硬件上处理的中间结果和输出张量)。这导致 Native Heap 内存下降。

为了实现高效的硬件加速,数据(张量)需要被放置在硬件驱动可以直接访问的内存区域。模型运行时会使用特殊的系统 API 来分配这些内存,而不是标准的 malloc。Android Studio Profiler 的 Native Heap 类别主要跟踪标准的 C/C++ 运行时分配。当内存被分配到 Ashmem、ION 或其他驱动管理的内存池时,它们在 dumpsys meminfo 的分类中可能被归类为 SharedPrivate Clean 或未被精确识别的系统保留内存,最终落入 Profiler 的 Others(其他)类别中。

另外一个可能的原因是系统或运行时内部调整。如果模型运行时(如 TFLite)在推理时创建了新的执行图(Execution Plan)或缓存了更多的上下文信息,这些非张量数据可能以系统未明确分类的方式被分配,计入 Others。某些高性能计算库会尝试使用大内存页来提高内存访问效率,这些内存页在某些 Profiler 版本中可能不会被准确地归类到 Native Heap。

简化方式一 MediaPipe Tasks

底层依然基于 LiteRT 的运行时来运行端侧AI模型,只是在应用层进行了封装,提供了更方便的接口。

简化方式二 AI Core 应用进行IPC通信

这个方法,便利性上较前两种方式更进了一步。Google 直接将Gemini Nano模型的下载,加载,推理都封装在 AI Core 中,应用层只需要调用AIDL接口和 AI Core 进行通信即可。

Google介绍:Gemini Nano 是我们专为设备端任务打造的最高效模型,它直接在移动芯片上运行,从而支持一系列重要用例。设备端运行支持数据无需离开设备的功能,例如在端到端加密消息应用中提供消息回复建议。它还能通过确定性延迟实现一致的体验,即使在没有网络的情况下也能始终使用各项功能。

图中的Lora是什么呢?

LoRA (Low-Rank Adaptation) 是一种用于微调(fine-tuning)大型预训练模型的技术,比如大型语言模型 (LLMs) 或图像生成模型 (如 Stable Diffusion)。

简单来说,LoRA 的核心思想是:在不修改原始大模型参数的情况下,通过向模型中注入少量可训练的层(或称为适配器)来适应新的任务或数据。

可以类比 Kotlin的扩展函数 来理解。

需要注意,目前测试版的 AI Core 只有 Pixel 9 及以上的设备支持。

使用AICore来和Genimi Nano模型进行通信的步骤非常简单。首先加入gradle依赖:

implementation("com.google.ai.edge.aicore:aicore:0.0.1-exp01")

注意最低SDK需要31及以上。

在此仅做最小功能验证,直接在Composable组合项中观察一个顶层变量 aiCoreOutput 这个 StateFlow 的状态变化,在顶层方法中出发通信逻辑,结果会更新到 aiCoreOutput 中。

val aiCoreOutput = MutableStateFlow("")

@SuppressLint("StaticFieldLeak")
val generationConfig = generationConfig {
    context = appContext
    temperature = 0.2f
    topK = 16
    maxOutputTokens = 256
}

val downloadCallback = object : DownloadCallback {
    override fun onDownloadProgress(totalBytesDownloaded: Long) {
        super.onDownloadProgress(totalBytesDownloaded)
        println("Download progress: $totalBytesDownloaded")
    }

    override fun onDownloadCompleted() {
        super.onDownloadCompleted()
        println("Download completed")
    }
}

val downloadConfig = DownloadConfig(downloadCallback)
val generativeModel = GenerativeModel(
    generationConfig = generationConfig,
    downloadConfig = downloadConfig
)

suspend fun startChat(input: String) {
    runCatching {
        val response = generativeModel.generateContent(input)
        print(response.text)
        aiCoreOutput.value = response.text.toString()
    }.onFailure { e ->
        e.printStackTrace()
    }
}

fun closeChatResponse() {
    println("Closing chat response")
    generativeModel.close()
}

Android 平台自定义使用 LiteRT

在 Android 上,可以使用 Java 或 C++ API 执行 LiteRT 推理。通过 Java API 提供了便利,可以直接在 Android 应用中使用 activity 类。C++ API 提供了更高的灵活性和速度,但可能需要 编写 JNI 封装容器以在 Java 层和 C++ 层之间移动数据。

运行架构解析

LiteRT 模型需要特殊的运行时环境才能执行,并且传入模型的数据必须采用特定的数据格式(称为张量)。当模型处理数据(称为运行推理)时,它会将预测结果生成为新的张量,并将其传递给 Android 应用,以便应用执行操作,例如向用户显示结果或执行其他业务逻辑。

在功能设计层面,您的 Android 应用需要以下元素才能运行 LiteRT 模型:

  • 用于执行模型的 LiteRT 运行时环境
  • 模型输入处理程序,用于将数据转换为张量
  • 模型输出处理脚本,用于接收输出结果张量并将其解读为预测结果

Google Play 服务的运行时

使用 Java API 访问 Google Play 服务中的 LiteRT。具体而言,Google Play 服务中的 LiteRT 可通过 LiteRT 解释器 API 来使用。

使用 Interpreter API

TensorFlow 运行时提供的 LiteRT 解释器 API 提供了一个用于构建和运行机器学习模型的通用接口。按照以下步骤操作,即可使用 Google Play 服务中的 TensorFlow Lite 运行时通过 Interpreter API 运行推理。

1. 添加项目依赖项

注意: Google Play 服务中的 LiteRT 使用 play-services-tflite 软件包。 将以下依赖项添加到您的应用项目代码中,以访问 LiteRT 的 Play 服务 API:

dependencies {
...
    // LiteRT dependencies for Google Play services
    implementation 'com.google.android.gms:play-services-tflite-java:16.1.0'
    // Optional: include LiteRT Support Library
    implementation 'com.google.android.gms:play-services-tflite-support:16.1.0'
...
}

2. 添加了 LiteRT 的初始化

在之前使用 LiteRT API 时,初始化 Google Play 服务 API 的 LiteRT 组件:

val initializeTask: Task<Void> by lazy { TfLite.initialize(this) }

注意: 请确保 TfLite.initialize 任务在执行访问 LiteRT API 的代码之前完成。使用 addOnSuccessListener() 方法,如下一部分所示。

3. 创建解释器并设置运行时选项

使用 InterpreterApi.create() 创建解释器,并通过调用 InterpreterApi.Options.setRuntime() 将其配置为使用 Google Play 服务运行时 ,如以下示例代码所示:

import org.tensorflow.lite.InterpreterApi
import org.tensorflow.lite.InterpreterApi.Options.TfLiteRuntime
...
private lateinit var interpreter: InterpreterApi
...
initializeTask.addOnSuccessListener {
  val interpreterOption =
    InterpreterApi.Options().setRuntime(TfLiteRuntime.FROM_SYSTEM_ONLY)
  interpreter = InterpreterApi.create(
    modelBuffer,
    interpreterOption
  )}
  .addOnFailureListener { e ->
    Log.e("Interpreter", "Cannot initialize interpreter", e)
  }

您应使用上述实现,因为它可以避免阻塞 Android 界面线程。如果您需要更密切地管理线程执行,可以向解释器创建添加 Tasks.await() 调用:

import androidx.lifecycle.lifecycleScope
...
lifecycleScope.launchWhenStarted { // uses coroutine
  initializeTask.await()
}

警告: 请勿在前景界面线程上调用 .await(),因为这会中断界面元素的显示,从而导致用户体验不佳。

4. 运行推理

使用您创建的 interpreter 对象,调用 run() 方法以生成推理结果。

interpreter.run(inputBuffer, outputBuffer)

硬件加速

借助 LiteRT,您可以使用专用硬件处理器(例如图形处理单元 GPU)来提升模型的性能。您可以使用称为“委托”的硬件驱动程序来利用这些专用处理器。

GPU 委托通过 Google Play 服务提供,并且会动态加载,就像 Interpreter API 的 Play 服务版本一样。

检查设备兼容性

并非所有设备都支持使用 TFLite 进行 GPU 硬件加速。为了减少错误和潜在的崩溃,请使用 TfLiteGpu.isGpuDelegateAvailable 方法检查设备是否与 GPU 委托兼容。

使用此方法可确认设备是否与 GPU 兼容,并在不支持 GPU 时使用 CPU 作为后备。

useGpuTask = TfLiteGpu.isGpuDelegateAvailable(context)

获得 useGpuTask 等变量后,您可以使用它来确定设备是否使用 GPU 委托。

val interpreterTask = useGpuTask.continueWith { task ->
  val interpreterOptions = InterpreterApi.Options()
      .setRuntime(TfLiteRuntime.FROM_SYSTEM_ONLY)
  if (task.result) {
      interpreterOptions.addDelegateFactory(GpuDelegateFactory())
  }
  InterpreterApi.create(FileUtil.loadMappedFile(context, MODEL_PATH), interpreterOptions)
}
使用 Interpreter API 的 GPU

如需将 GPU 委托与 Interpreter API 搭配使用,请执行以下操作:

更新项目依赖项以使用 Play 服务中的 GPU 委托:

implementation 'com.google.android.gms:play-services-tflite-gpu:16.2.0'

在 TFlite 初始化中启用 GPU 委托选项:

TfLite.initialize(context,
  TfLiteInitializationOptions.builder()
    .setEnableGpuDelegateSupport(true)
    .build())

在解释器选项中启用 GPU 代理:通过调用 InterpreterApi.Options() 中的 addDelegateFactory() 将代理工厂设置为 GpuDelegateFactory:

val interpreterOption = InterpreterApi.Options()
  .setRuntime(TfLiteRuntime.FROM_SYSTEM_ONLY)
  .addDelegateFactory(GpuDelegateFactory())

独立的 LiteRT 运行时

通常,您应使用 Google Play 服务提供的运行时环境,因为它比标准环境更节省空间,因为它会动态加载,从而缩减应用大小。Google Play 服务还会自动使用最新的稳定版 LiteRT 运行时,随着时间的推移,为您提供更多功能并提升性能。如果您 在未包含 Google Play 服务的设备上提供应用,或者需要密切管理 ML 运行时环境,则应使用标准 LiteRT 运行时 。此选项会将额外的代码捆绑到您的应用中,让您可以更好地控制应用中的机器学习运行时,但代价是增加应用的下载大小。

您可以通过将 LiteRT 开发库添加到应用开发环境,在 Android 应用中访问这些运行时环境。

步骤一:添加 LiteRT 核心依赖

应用级 build.gradle 文件(通常是 app/build.gradle)中,添加 LiteRT 核心库的依赖项。

dependencies {
    // 1. LiteRT 核心库(Standalone/Bundled 运行时)
    implementation 'org.tensorflow:tensorflow-lite:LITERT_VERSION'

    // 2. 推荐:用于模型元数据和实用程序的库
    implementation 'org.tensorflow:tensorflow-lite-support:LITERT_VERSION'

    // 3. 可选:如果您需要特定的硬件加速(Delegate),请添加对应的独立依赖。
    // 例如,使用 GPU Delegate:
    implementation 'org.tensorflow:tensorflow-lite-gpu:LITERT_VERSION'

    // ... 其他依赖项
}

注意: 应将 LITERT_VERSION 替换为当前的稳定版本号。这些库就是以前的 org.tensorflow:tensorflow-lite 系列,是独立于 Google Play 服务的。

步骤二:将 LiteRT 模型文件添加到项目

将的 .tflite 模型文件放置在 Android 项目的 assets 文件夹中:

  1. app/src/main/ 目录下创建或找到 assets 文件夹。
  2. 将您的 model_name.tflite 文件复制到此文件夹中。

步骤三:在 Kotlin/Java 中加载和运行模型

使用 LiteRT 的 Interpreter 类来加载模型并执行推理。

import org.tensorflow.lite.Interpreter
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.io.FileInputStream
import java.nio.channels.FileChannel

// ... 您的 Activity 或 Fragment ...

fun runInference() {
    // 1. 配置 LiteRT 解释器选项
    val options = Interpreter.Options()
    
    // 2. [可选] 如果添加了 GPU 依赖,可以设置 GPU Delegate
    // **注意:** 独立的 GPU Delegate 在某些设备上可能不如 Play Services 托管的稳定。
    // val gpuDelegate = GpuDelegate()
    // options.addDelegate(gpuDelegate)

    // 3. 创建解释器实例
    val interpreter: Interpreter
    try {
        interpreter = Interpreter(loadModelFile(), options)
    } catch (e: Exception) {
        // 处理加载模型时的错误
        e.printStackTrace()
        return
    }

    // --- 假设输入和输出张量大小 ---
    // 示例:输入形状 [1, 224, 224, 3] (Float32)
    val inputShape = interpreter.getInputTensor(0).shape() 
    val outputShape = interpreter.getOutputTensor(0).shape()
    
    // 创建输入和输出缓冲区
    val inputBuffer = ByteBuffer.allocateDirect(
        inputShape[0] * inputShape[1] * inputShape[2] * inputShape[3] * 4 // 4 bytes for float
    ).apply { order(ByteOrder.nativeOrder()) }
    
    val outputBuffer = ByteBuffer.allocateDirect(
        outputShape[0] * outputShape[1] * 4 // 4 bytes for float
    ).apply { order(ByteOrder.nativeOrder()) }
    
    // [TODO] 准备您的输入数据,并将其写入 inputBuffer
    // 例如:将您的图像数据转换为 float 数组并写入 inputBuffer
    
    // 4. 运行模型推理
    interpreter.run(inputBuffer, outputBuffer)
    
    // 5. [TODO] 处理 outputBuffer 中的结果
    
    // 6. 清理资源
    // interpreter.close()
    // gpuDelegate.close() // 如果使用了 Delegate
}

/**
 * 从 assets 文件夹加载 .tflite 模型文件
 */
private fun loadModelFile(): ByteBuffer {
    val fileDescriptor = assets.openFd("model_name.tflite")
    val inputStream = FileInputStream(fileDescriptor.fileDescriptor)
    val fileChannel = inputStream.channel
    val startOffset = fileDescriptor.startOffset
    val declaredLength = fileDescriptor.declaredLength
    
    return fileChannel.map(FileChannel.MapMode.READ_ONLY, startOffset, declaredLength)
}

步骤四:清理和资源管理

不再需要解释器时,务必调用 interpreter.close() 来释放本地资源,这对于防止内存泄漏非常重要。如果使用了硬件加速委托(如 GPU Delegate),也要调用其 close() 方法。

// 确保在 Activity/Fragment 生命周期结束时或不再需要时关闭
override fun onDestroy() {
    interpreter.close()
    // gpuDelegate?.close()
    super.onDestroy()
}

通过这些步骤,Android 应用将使用独立的 LiteRT 运行时进行机器学习推理,完全不依赖 Google Play 服务。

自己构建 LiteRT

某些高级自定义操作可能需要自己构建LiteRT的整个依赖库,直接参考官方指南:

Build LiteRT for Android

【AI】端侧模型部署llama.cpp篇

【AI】端侧模型部署llama.cpp篇

本文介绍了借助llama.cpp开源框架运行Android平台上的AI小模型的流程

接上文:

【AI】大模型开发流程和运行环境简介

llama.cpp

作为一名 Android 开发者,llama.cpp 打开了在移动应用中实现端侧 AI 的全新大门。

llama.cpp 是一个由 Georgi Gerganov 开发的开源项目,其核心是用 C/C++ 编写的。它的主要目标是让大型语言模型 (LLMs) 能够在各种硬件上高效地运行推理,尤其是在本地设备上,包括那些没有高端独立 GPU 的普通电脑或移动设备。该项目的愿景是 “民主化”LLMs 的部署 ,让每个人都能在自己的设备上体验和使用这些强大的模型,而不仅仅依赖于昂贵的云服务。

对于Android平台,我们可以通过 Android NDKllama.cpp 的 C/C++ 代码编译为 .so 库,并在您的 Java/Kotlin 代码中通过 JNI 调用它。llama.cpp 让您可以在 Android 应用中直接拥有一个“本地的智能大脑”,从而创造出前所未有的智能应用体验。

llama.cpp 的设计理念是追求极致的效率、最小化外部依赖、广泛的硬件兼容性以及高度的灵活性

llama.cpp 并非一个虚拟机,而是一个高效的、C/C++ 实现的 LLM 推理引擎。它通过模型量化、GGUF 格式、底层硬件优化、多平台支持以及灵活的 API 接口,极大地降低了在个人电脑和边缘设备上运行大型语言模型的门槛。

模型量化

之前已经总结过,权重等参数是如何存储于大模型中的:

【AI】LLM中张量的计算

量化 是一种技术,可以将模型中通常以 32 位浮点数(FP32)或 16 位浮点数(FP16)存储的权重和激活值,转换为更低精度的格式,如 8 位整数(INT8)、4 位整数(INT4),甚至更低。降低了存储和传输模型的成本。使得更大的模型能够加载到有限的 RAMVRAM 中。

模型的量化(Model Quantization)是一个在机器学习,尤其是深度学习领域中非常重要的概念,其核心目的是让模型变得更小、更快、更节能

简单来说,模型的量化就是降低模型中数据的精度

一个 FP32 的数字需要 4 字节(32位)。一个有数十亿参数的模型需要巨大的内存,计算开销相当高,浮点数计算比整数计算更耗时、耗电。

量化是如何工作的?

量化 就是将这些高精度的浮点数,转换成低精度的整数或更小的浮点数,比如:

  • 8 位整数 (INT8)
  • 4 位整数 (INT4 或 Q4_K)
  • 16 位浮点数 (FP16 或 BF16)

假设你有一个模型的权重值是 0.785。在 FP32 中,它需要 4 个字节来存储。通过量化,它可以被映射到一个 8 位整数,比如 200,同时有一个缩放因子(Scale)和零点(Zero Point)来帮助在推理时近似还原原来的浮点数。

量化带来的好处

优势描述
模型尺寸减小将 FP32(4 字节)量化到 INT4(0.5 字节),理论上模型大小可以缩小8倍。这使得模型可以部署到内存有限的设备上。
推理速度加快低精度的数据在特定的硬件(如 CPU、移动端 NPU)上可以更快地计算,因为它减少了内存访问,并允许使用更高效的整数运算单元。
能耗降低更少的计算和更小的内存访问量,意味着模型在运行时消耗的电量更少,这对于移动设备和边缘计算非常重要。

量化类型分为两种:

  • 训练后量化 (Post-Training Quantization, PTQ),这是最常见的量化方式。模型在 训练完成后 ,才进行量化转换。这种方式不需要重新训练模型,实现简单,成本低。对精度损失不太敏感,或希望快速部署模型的场景。
  • 量化感知训练 (Quantization-Aware Training, QAT),这是在 训练过程中 就模拟量化后的低精度运算。由于模型在训练时就“知道”自己会被量化,因此可以更好地调整参数以 最小化精度损失 ,通常能获得比 PTQ 更好的性能。适合对精度要求较高,且愿意投入额外训练时间的场景。

在移动设备(如 Android 手机)上部署深度学习模型时, INT8量化 是常用的优化手段,因为移动设备的内存和计算资源都相对有限。

llama.cpp 框架特点

轻量与高效的 C/C++ 实现

llama.cpp 完全使用 C 和 C++ 编写,不依赖于大型的深度学习框架(如 TensorFlow 或 PyTorch)的运行时库。这意味着它编译出来的程序 体积非常小,运行时内存占用也低 。这对于资源受限的移动设备来说至关重要。

除了底层的 BLAS (基本线性代数子程序库) 或特定硬件的计算库(如 CUDA、Metal), llama.cpp 几乎没有其他复杂的外部依赖。这使得它非常容易编译和部署。

llama.cpp 对 CPU 进行了大量底层优化,利用了各种 CPU 指令集(如 x86-64 上的 AVX/AVX2/AVX-512,以及 ARM 上的 Neon)。这让 LLMs 可以在没有独立 GPU 的设备上,仅仅依靠 CPU 也能获得令人惊讶的推理速度。

广泛的硬件加速支持

除了强大的 CPU 优化,llama.cpp 还支持多种 GPU 和专用硬件加速,这意味着它是跨平台的,跨平台支持在任何行业都备受推崇,无论是游戏、人工智能还是其他类型的软件。赋予开发者在他们想要的系统和环境中运行软件所需的自由永远不是一件坏事。

  • Apple Silicon (Metal):针对苹果 M 系列芯片的 Metal API 进行了优化,充分利用了其强大的统一内存架构和神经网络引擎。
  • NVIDIA GPU (CUDA):支持 NVIDIA 显卡,通过 CUDA 加速推理,性能非常出色。
  • AMD GPU (hipBLAS):兼容 AMD GPU。
  • Intel GPU (SYCL/oneAPI):支持英特尔的集成和独立显卡。
  • 通用 GPU 后端 (Vulkan/OpenCL):这对于 Android 开发者尤为重要。通过 OpenCL 后端,llama.cpp 能够利用 Android 设备中 SoC(System on Chip)内置的 GPU 或 DSP(数字信号处理器,如高通骁龙的 Hexagon DSP)进行加速,将推理负载从 CPU 转移到更擅长并行计算的硬件上。
  • CPU + GPU 混合推理:对于一些较大的模型,即使单张 GPU 的显存不足以容纳整个模型,llama.cpp 也能将模型的某些层加载到 GPU 上运行,其余部分则在 CPU 上运行,实现资源的有效利用。

GGUF 文件格式

GGML

GGML 是一种为机器学习设计的文件格式和张量库,它最初由开发者 Georgi Gerganov 创建(GGML = Gerganov’s General Machine Learning)。它的核心目标是高效地在 CPU 上运行大型机器学习模型,特别是大型语言模型(LLMs),并且支持各种硬件平台。

GGML 的出现,是将 LLMs 带到消费级硬件上的关键一步。在它之前,运行大型模型通常需要昂贵的 GPU 和复杂的配置。GGML 通过一系列创新技术,让这些模型能够在普通的个人电脑上也能跑起来。

GGML 的核心是用 C 语言编写的,这意味着它的 执行效率非常高 ,并且依赖性极少。这使得它非常轻量级,可以在各种不同的操作系统和硬件上轻松编译和运行,包括 macOS、Linux、Windows,甚至 iOS 和 Android。

GGML 最初的重点在于在 CPU 上实现高效推理。它利用了现代 CPU 的特性,例如 SIMD(单指令多数据)指令集 ,比如 Intel 的 AVX、AVX2、AVX512 和 ARM 的 NEON,这些指令允许 CPU 同时处理多个数据点,从而加速矩阵乘法等并行计算。可以利用 CPU 的多个核心并行执行计算任务。

GGUF格式的出现

GGUF (GPT-Generated Unified Format) 就是 GGML 文件格式的最新演进版本。GGUF 在 GGML 的基础上,提供了更强的灵活性、更好的向后兼容性,并能包含更多的元数据(例如分词器信息、提示模板等),使其成为目前社区首选的本地 LLM 格式。

可以把它想象成一个 包含了模型“大脑”里所有知识的“盒子” ,这个盒子设计得非常紧凑和高效,便于在各种设备上快速打开和使用。

GGUF 格式支持从全精度 (FP32) 到半精度 (FP16) 以及多种低精度(如 INT8、INT5、INT4、INT2)的模型量化。量化可以在牺牲极小精度损失的情况下,大幅减小模型体积,降低内存占用和计算需求。这对于在手机上运行大型模型至关重要,能让原本无法加载的模型变得可用。

GGUF 文件不仅包含模型权重,还打包了模型的词表、超参数、架构信息和特殊 token ID 等所有运行所需的元数据。一个 GGUF 文件就是一个独立的、可运行的模型包。

此外,GGUF 支持内存映射。这意味着操作系统可以直接将文件内容映射到内存中,而无需将整个模型完全复制到 RAM。这大大加快了模型加载速度,并允许在物理内存不足的情况下也能运行大模型(通过操作系统的虚拟内存管理)。

模型文件命名约定

GGUF 遵循命名约定, <BaseName><SizeLabel><FineTune><Version><Encoding><Type><Shard>.gguf 每个组件之间用 - 分隔。这样做的最终目的是让人们能够一目了然地了解模型的最重要细节。

这些组件包括:

  • BaseName:模型基础类型或架构的描述性名称。
  • SizeLabel:参数权重类(对排行榜有用)表示为<expertCount>x<count><scale-prefix>
  • FineTune:模型微调目标的描述性名称(例如聊天、指导等…)
  • 版本:(可选)表示模型版本号,格式为v<Major>.<Minor>
  • 编码:表示应用于模型的权重编码方案。内容、类型组合和排列由用户代码决定,并可能根据项目需求而变化。
  • 类型:表示 gguf 文件的类型及其预期用途
  • Shard:(可选)表示并表明模型已被拆分为多个分片,格式为<ShardNum>-of-<ShardTotal>

例如:Mixtral-8x7B-v0.1-KQ2.gguf

型号名称:Mixtral
专家数量:8
参数数量:7B
版本号:v0.1
权重编码方案:KQ2

GGUF 文件的结构

一个 GGUF 文件大致可以分为两个主要部分:

  1. 头部 (Header):包含 GGUF 文件的魔数(用于识别文件类型)、版本号以及一些全局元数据(如模型总层数、维度等)。
  2. 元数据 (Metadata) 和张量 (Tensors)
    • KV 对 (Key-Value Pairs):存储了模型的各种超参数、架构信息、词表和一些其他配置。这些都是以键值对的形式存储的,方便访问。
    • 张量数据 (Tensor Data):这是模型真正的权重数据。每个张量都会有其名称、维度和数据类型(例如,量化后的 INT4、INT8 或 FP16 等)。这些数据会按照特定的对齐方式存储,以确保高效读取。

运行原理简介

详细运行流程见原文:

【AI】Understanding how LLM inference works with llama.cpp

即使没有高性能的独立 GPU 的设备也可以运行大模型,但这通常会有一些重要的限制和权衡

CPU 也能执行并行计算 ,现代 CPU 通常有多个核心,并且支持 SIMD (Single Instruction, Multiple Data) 指令集(如 Intel 的 AVX、SSE 指令集),这使得它们能够同时处理少量数据。例如机器学习框架(如 TensorFlow 或 PyTorch),在设备没有GPU时,会自动回退到使用 CPU 来执行所有的矩阵乘法和其他计算。这些框架的 CPU 版本也会进行高度优化,以尽可能利用 CPU 的并行能力。

传统的 CPU 指令(称为 标量指令 或 SISD - Single Instruction, Single Data)一次只能对一个数据对(例如,两个整数或浮点数)进行操作。相比之下,SIMD 指令将多个数据项打包到一个特殊的 宽寄存器 中,然后一次性对所有这些数据项执行相同的操作。向量寄存器是 SIMD 的关键。它们比普通的通用寄存器宽得多。常见的宽度有 128位、256位,以及最新的 512位。假设有一个 256 位的寄存器和 32 位的整数。您可以将 256/32=8 个整数打包到这个寄存器中。当 CPU 执行一个 SIMD 加法指令时,它会在一个时钟周期内同时对这 8 个整数执行加法操作。这就像将一条生产线变成了八条。

相比于大模型的训练,推理阶段的计算量相对较小 ,训练大模型需要极其强大的 GPU,因为它涉及数万亿次的参数更新,需要多次迭代和反向传播。而在 推理(Inference) 阶段,即模型用于实际预测时,只需要进行前向传播。虽然计算量依然庞大,但比训练时少得多。对于量化后的模型,推理的计算需求会进一步降低。

本质上,llama.cpp 加载数据,构建计算图并进行计算。主要关注点也是在于使用高效的 SIMD 指令在 CPU 上运行——这内置于库的核心实现中(ggml.c)。后端(ggml-cuda、ggml-metal等)用于在 GPU 加速器上计算图。

因此,它是一个通用 API,可以更轻松地在项目中运行 gguf 模型。如果有非常具体的需求或用例,也可以直接在其基础上构建 gguf,或者通过删除不必要的内容来创建一个精简版本 llama.cpp

它使用 llama_init_from_file 函数从 gguf 文件初始化一个 llama 上下文。此函数读取 gguf 文件的头文件和正文,并创建一个 llama 上下文对象,该对象包含模型信息和运行模型的后端(CPU、GPU 或 Metal)。

再使用 llama_tokenize 函数对输入文本进行标记。此函数根据 gguf 文件头中指定的标记器将输入文本转换为标记序列。这些标记存储在一个 llama 标记数组中, llama 标记是表示标记 ID 的整数。

在执行推理生成时,通过 llama_generate 函数生成输出文本。此函数将输入标记和 llama 上下文作为参数,并在后端运行模型。它使用 gguf 文件头中指定的计算图执行模型的前向传递并计算下一个标记的概率。然后,它从概率分布中采样下一个标记并将其附加到输出标记中。它会重复此过程,直到文本结束标记或达到最大标记数。输出标记存储在另一个 llama 标记数组中。

最后通过 llama_detokenize 函数对输出文本进行去标记化。该函数根据 gguf 文件头中指定的标记器将输出标记转换为文本字符串。它会处理特殊标记,例如文本结束标记、填充标记和未知标记,并返回最终的输出文本。

部署运行实操

接下来介绍下如何在项目中集成llama.cpp,从而加载gguf格式的模型,运行本地的小模型。

一、使用Termux命令行编译运行

这种方法就是将 Android 设备当作 Linux 设备来使用,手机需要安装Termux。

可以在Github Releases 选择 termux-app_v0.118.2+github-debug_arm64-v8a.apk 下载,并且安装到手机。

Releases · termux/termux-app

下载 llama.cpp 库:

# 切换国内源
termux-change-repo
apt list --upgradable
# 安装依赖工具
pkg install -y cmake git build-essential
# 下载 llama.cpp
git clone https://github.com/ggml-org/llama.cpp.git
# 如果git下不下来,通过scp拷贝进去
scp -P 8022 .\llama.cpp-master.zip u0_a456@192.168.31.44:~

编译llama.cpp源代码:

# 进入目录
cd llama.cpp
# 创建build文件,并且进入文件夹
mkdir build && cd build
# 生成编译配置,-DGGML_CUDA=OFF 关闭GPU
cmake .. -DGGML_CUDA=OFF 
# 4个线程编译
make -j4
# 编译完成目录在/bin
ls ~/llama.cpp/build/bin
# bin添加到环境变量中
echo 'export PATH=$PATH:~/llama.cpp/build/bin/' >> ~/.bashrc
source ~/.bashrc

直接从 Hugging Face 下载 .gguf 文件,避免转换步骤。我下载的是 DeepSeek-R1-Distill-Qwen-1.5B-Q2_K.gguf 。网络问题,推荐在电脑端下载完毕,通过USB使用adb或者文件模式,推送到手机端。

注意Termux默认是无法操作手机文件系统的,需要执行命令来获取权限,初始化文件管理系统。

termux-setup-storage

出现管理所有文件的权限授予弹窗,打开之后将文件复制到内部目录:

可以先试试运行效果,使用 llama-cli 直接在命令行中启动:

llama-cli -m DeepSeek-R1-Distill-Qwen-1.5B-Q2_K.gguf

llama-cli,即 ​​CLI 模式​​(Command-Line Interface 模式)是指通过命令行直接运行模型进行推理(文本生成)的方式,而不是通过 API 或图形界面。这是 llama.cpp 最基础的使用方式,适合本地测试、脚本调用或服务器部署。

运行效果如下:

也可以使用 llama-server 的方式启动:

llama-server -m DeepSeek-R1-Distill-Qwen-1.5B-Q2_K.gguf --port 8080 --host 0.0.0.0

手机端client直接访问

通过 llama-server 作为服务器启动之后,我们可以直接在手机端编写client,通过http请求来访问,直接和这个服务交互。这里大部分可以复用之前写的和deepseek官方api的请求逻辑。

网络Repository代码:

class DeepseekChatRepository(private val ktorClient: KtorClient) {

    companion object {
        const val BASE_URL =
            "https://api.deepseek.com"
        const val LOCAL_SERVER = "http://0.0.0.0:8080/v1"
        const val COMMON_SYSTEM_PROMT = "你是一个人工智能系统,可以根据用户的输入来返回生成式的回复"
        const val ENGLISH_SYSTEM_PROMT =
            "You are a English teacher, you can help me improve my English skills, please answer my questions in English."
        const val API_KEY = "xxxxxxxxxxxxxxxxx"
        const val MODEL_NAME = "deepseek-chat"
    }

    suspend fun localLLMChat(chat: String) = withContext(Dispatchers.IO) {
        ktorClient.client.post("${LOCAL_SERVER}/chat/completions") {
            // 配置请求头
            headers {
                append("Content-Type", "application/json")
            }
            setBody(
                DeepSeekRequestBean(
                    model = "DeepSeek-R1-Distill-Qwen-1.5B-Q2_K",
                    max_tokens = 256,
                    temperature = 0.7f,
                    stream = false,
                    messages = listOf(
                        RequestMessage(COMMON_SYSTEM_PROMT, ChatRole.SYSTEM.roleDescription),
                        RequestMessage(chat, ChatRole.USER.roleDescription)
                    )
                )
            )
        }.body<LocalModelResult>()
    }
}

界面上维护一个chatListState,里面是一个

data class AiChatUiState(
    val chatList: List<ChatItem> = listOf(),
    val listSize: Int = chatList.size
) {
    fun toUiState() = AiChatUiState(chatList = chatList, listSize = listSize)
}

data class ChatItem(
    val content: String,
    val role: ChatRole,
)

界面观察这个State响应式刷新即可。

运行结果:

局域网内其他设备访问

除了同一设备直接访问本地服务,在同一个局域网中,比如电脑端,我们也可以使用Python,通过 openai 的Python开发套件,和手机端运行的服务进行通信:

import requests
import json
import time

API_URL = "http://192.168.31.44:8080/v1/chat/completions"

payload = {
    "model": "DeepSeek-R1-Distill-Qwen-1.5B-Q2_K",  # llama-server 中可随意写
    "messages": [
        {"role": "system", "content": "你是一个英语学习助手。"},
        {"role": "user", "content": "请用中文解释单词 ability 的含义,并给出一个英文例句。"}
    ],
    "temperature": 0.7,
    "max_tokens": 256,
    "stream": False
}

# 记录开始时间
start_time = time.time()

# 发送请求
response = requests.post(API_URL, headers={"Content-Type": "application/json"}, data=json.dumps(payload))

# 记录结束时间
end_time = time.time()

if response.ok:
    result = response.json()
    message = result['choices'][0]['message']['content']
    print("模型回复:\n", message)
    
    # 处理 token usage 和速度统计
    usage = result.get("usage", {})
    total_tokens = usage.get("total_tokens", "未知")
    elapsed = end_time - start_time
    
    print(f"\n总 tokens: {total_tokens}")
    print(f"耗时: {elapsed:.2f} 秒")
    if isinstance(total_tokens, int) and elapsed > 0:
        print(f"生成速度: {total_tokens / elapsed:.2f} tokens/秒")
else:
    print("请求失败,状态码:", response.status_code)
    print(response.text)

二、单进程集成方案

上面那种在Termux中运行模型的方式还是感觉比较麻烦,每次也需要手动开启服务。

下面这种方案就是比较符合 Android 设备上运行的直观预期,通过一个APP页面来承载功能,在一个应用中,以用户友好的 UX交互 来和本地模型进行通信。使用JNI开发接口和llama.cpp交互。

底层依然是使用 llama.cpp 加载和执行 GGUF 模型。由于 llama.cpp 是用纯 C/C++ 编写的,因此很容易在apk编译阶段利用 AndroidStudio 的NDK工具,打包为 .so 动态库,在端侧运行。

GGUF文件读取

首先,定义JNI函数,第一步需要加载 gguf 文件。在 Android 应用中,需要使用 Kotlin 语言来定义页面需要用到的接口,再到 Native 层使用 llama.cpp 的能力,来编写 C++ 的桥接代码。

class GGUFReader {
    companion object {
        init {
            System.loadLibrary("ggufreader")
        }
    }

    private var nativeHandle: Long = 0L

    suspend fun load(modelPath: String) =
        withContext(Dispatchers.IO) {
            nativeHandle = getGGUFContextNativeHandle(modelPath)
        }

    fun getContextSize(): Long? {
        assert(nativeHandle != 0L) { "Use GGUFReader.load() to initialize the reader" }
        val contextSize = getContextSize(nativeHandle)
        return if (contextSize == -1L) {
            null
        } else {
            contextSize
        }
    }

    fun getChatTemplate(): String? {
        assert(nativeHandle != 0L) { "Use GGUFReader.load() to initialize the reader" }
        val chatTemplate = getChatTemplate(nativeHandle)
        return chatTemplate.ifEmpty {
            null
        }
    }

    private external fun getGGUFContextNativeHandle(modelPath: String): Long

    private external fun getContextSize(nativeHandle: Long): Long

    private external fun getChatTemplate(nativeHandle: Long): String
}

nativeHandle 是一个长整型(Long)变量,代表指向本地(C/C++)端创建的 gguf_context 的指针。在 Native 代码里,gguf_context 是一个上下文对象,负责管理 GGUF 文件的读取操作。nativeHandle 唯一标识这个上下文对象,方便 Kotlin 代码引用。借助 nativeHandle 能把本地对象的地址传递给 Kotlin 代码,进而在 Kotlin 代码里调用本地函数操作这些对象。

定义的三个JNI方法作用分别如下:

  • getGGUFContextNativeHandle() : 加载模型文件,返回模型上下文的指针。
  • getContextSize() : 获取模型上下文的大小,即模型参数的数量。
  • getChatTemplate() : 获取模型的聊天模板,用于生成聊天对话的提示。

在Native层的代码中,实现也非常简单,引入 llama.cpp 中的 gguf.h 头文件。

在获取上下文指针的方法中,传入模型文件的绝对地址字符串,调用 gguf_init_from_file ,即可获取到 gguf_context 对象指针,转换回 jlong 类型传递给Kotlin即可。

第二,在获取模型参数数量的方法中,需要先从 gguf_context 中找到 architecture 字段,再根据 architecture 字段的值,拼接出 context_length 字段的名称,最后调用 gguf_get_val_u32 方法获取参数数量。

第三个方法是获取模型的聊天模板,需要先找到分词器 tokenizer.chat_template 字段,调用 gguf_get_val_str 方法获取字符串值。

#include "gguf.h"
#include <jni.h>
#include <string>

extern "C" JNIEXPORT jlong JNICALL
Java_com_stephen_llamacppbridge_GgufFileReader_getGGUFContextNativeHandle(JNIEnv *env, jobject thiz,
                                                                          jstring modelPath) {
    jboolean isCopy = true;
    const char *modelPathCStr = env->GetStringUTFChars(modelPath, &isCopy);
    // 初始化 GGUF 上下文所需的参数,不分配额外内存,上下文指针初始化为 nullptr
    gguf_init_params initParams = {.no_alloc = true, .ctx = nullptr};
    // 根据模型文件路径和初始化参数创建 GGUF 上下文
    gguf_context *ggufContext = gguf_init_from_file(modelPathCStr, initParams);
    env->ReleaseStringUTFChars(modelPath, modelPathCStr);
    return reinterpret_cast<jlong>(ggufContext);
}

extern "C" JNIEXPORT jlong JNICALL
Java_com_stephen_llamacppbridge_GgufFileReader_getContextSize(JNIEnv *env, jobject thiz,
                                                              jlong nativeHandle) {
    gguf_context *ggufContext = reinterpret_cast<gguf_context *>(nativeHandle);
    // 查找模型架构信息对应的键 ID
    int64_t architectureKeyId = gguf_find_key(ggufContext, "general.architecture");
    // 若未找到架构信息键 ID,返回 -1
    if (architectureKeyId == -1)
        return -1;
    // 获取模型架构信息
    std::string architecture = gguf_get_val_str(ggufContext, architectureKeyId);
    // 构建上下文长度信息对应的键名
    std::string contextLengthKey = architecture + ".context_length";
    // 查找上下文长度信息对应的键 ID
    int64_t contextLengthKeyId = gguf_find_key(ggufContext, contextLengthKey.c_str());
    // 若未找到上下文长度信息键 ID,返回 -1
    if (contextLengthKeyId == -1)
        return -1;
    uint32_t contextLength = gguf_get_val_u32(ggufContext, contextLengthKeyId);
    return contextLength;
}

extern "C" JNIEXPORT jstring JNICALL
Java_com_stephen_llamacppbridge_GgufFileReader_getChatTemplate(JNIEnv *env, jobject thiz,
                                                               jlong nativeHandle) {
    gguf_context *ggufContext = reinterpret_cast<gguf_context *>(nativeHandle);
    // 查找聊天模板信息对应的键 ID
    int64_t chatTemplateKeyId = gguf_find_key(ggufContext, "tokenizer.chat_template");
    // 存储聊天模板的字符串
    std::string chatTemplate;
    // 若未找到聊天模板信息键 ID,将聊天模板设为空字符串
    if (chatTemplateKeyId == -1) {
        chatTemplate = "";
    } else {
        // 若找到聊天模板信息键 ID,获取聊天模板信息
        chatTemplate = gguf_get_val_str(ggufContext, chatTemplateKeyId);
    }
    return env->NewStringUTF(chatTemplate.c_str());
}

模型的加载与对话

gguf 文件成功读取和加载后,就可以运行LLM的推理功能了。

根据 llama.cpp 的几个核心的方法,如加载,对话等功能,来编写对接的接口 C++ 类。

关于第一步加载模型 load_1model ,官方例程的JNI接口编写如下:

extern "C"
JNIEXPORT jlong JNICALL
Java_android_llama_cpp_LLamaAndroid_load_1model(JNIEnv *env, jobject, jstring filename) {
    // 获取模型的默认参数
    llama_model_params model_params = llama_model_default_params();

    // 将 Java String 转换为 C 风格字符串
    auto path_to_model = env->GetStringUTFChars(filename, 0);
    LOGi("Loading model from %s", path_to_model);

    // 调用 llama.cpp 核心函数加载模型
    auto model = llama_model_load_from_file(path_to_model, model_params);
    // 释放 C 风格字符串,防止内存泄漏
    env->ReleaseStringUTFChars(filename, path_to_model);

    if (!model) {
        LOGe("load_model() failed");
        // 如果加载失败,抛出 Java 异常
        env->ThrowNew(env->FindClass("java/lang/IllegalStateException"), "load_model() failed");
        return 0;
    }

    // 将 C++ 指针转换为 jlong 返回给 Java
    return reinterpret_cast<jlong>(model);
}

返回的是一个指向结构体 llama_model 的指针,这是 llama.cpp 库中最核心的结构体之一,它代表了加载到内存中的整个大语言模型 (LLM)。

这个 llama_model 结构体中包含了 模型的元数据、超参数、词汇表、所有权重张量以及硬件配置信息

struct llama_model {
    // 模型的类型(例如 LLaMA, Falcon, Mixtral 等)
    llm_type type = LLM_TYPE_UNKNOWN;
    // 模型的架构(llm_arch 是 llama.cpp 内部用于区分不同模型结构的枚举)
    llm_arch arch = LLM_ARCH_UNKNOWN;

    // 模型的名称或描述
    std::string name = "n/a";

    // 模型超参数:包含模型的固定配置,如层数、注意力头数、KV 缓存上下文长度等
    llama_hparams hparams = {};
    // 模型的词汇表(Vocabulary):包含 Token 列表及其与 ID 的映射关系
    llama_vocab   vocab;

    // 用于分类器模型(如 Sentiments Analysis)的标签列表
    std::vector<std::string> classifier_labels;

    // ggml_tensor* 是 ggml 库中的张量指针,存储模型的权重数据。
    // 这部分包含了不同模型架构共有的或用于输入处理的权重。

    // Token 嵌入层权重 (Token Embeddings)
    struct ggml_tensor * tok_embd   = nullptr;
    // Token 类型嵌入层权重(例如用于 BERT 风格模型区分句子 A 和 B)
    struct ggml_tensor * type_embd  = nullptr;
    // 位置嵌入层权重 (Positional Embeddings)
    
    ...

    // -------------------------------------------------------------------------
    // 3. 特殊张量 (Specialized Tensors)
    // -------------------------------------------------------------------------

    // **分类器张量 (Classifier Tensors)**
    struct ggml_tensor * cls       = nullptr; // 分类器权重
    struct ggml_tensor * cls_b     = nullptr; // 分类器偏置
    struct ggml_tensor * cls_out   = nullptr; // 分类器输出层权重
    struct ggml_tensor * cls_out_b = nullptr; // 分类器输出层偏置

    // **1D 卷积张量 (用于某些早期模型如 GPT-2 或特殊层)**
    struct ggml_tensor * conv1d   = nullptr;
    struct ggml_tensor * conv1d_b = nullptr;

    ...

    // 4. 配置与运行状态 (Configuration and Runtime State)

    ...

    // 创建上下文内存结构体(KV 缓存等)
    // note: can mutate `cparams`
    // TODO: move this to new llm_arch_model_i interface
    llama_memory_i * create_memory(const llama_memory_params & params, llama_cparams & cparams) const;

    // 构建 ggml 计算图:将模型计算逻辑转化为可执行的计算图
    // TODO: move this to new llm_arch_model_i interface
    ggml_cgraph * build_graph(const llm_graph_params & params) const;
};

llama_model_load_from_file 函数最终调用到了 llama_model_load_from_file_impl 函数,看看这里面做了哪些工作:

/**
 * @brief 核心实现函数:从文件加载 LLM 模型到 llama_model 结构体中。
 * @param path_model 模型文件的主要路径。
 * @param splits 如果模型被分割成多个文件,包含其余文件路径的向量。
 * @param params 模型加载参数,如设备选择、KV 缓存大小等。
 * @return 成功加载的 llama_model 指针,失败返回 nullptr。
 */
static struct llama_model * llama_model_load_from_file_impl(
        const std::string & path_model,
        std::vector<std::string> & splits,
        struct llama_model_params params) {
    
    // 初始化 ggml 库的计时器
    ggml_time_init();
    // 1. 后端检查 (Backend Check)
    // 如果不是只加载词汇表,并且没有注册任何计算后端,则报错。
    // 第二个条件是为了确保 llama.cpp 运行时环境中有可用的硬件(或软件)模块来执行模型的实际计算。
    if (!params.vocab_only && ggml_backend_reg_count() == 0) {
        LLAMA_LOG_ERROR("%s: no backends are loaded. hint: use ggml_backend_load() or ggml_backend_load_all() to load a backend before calling this function\n", __func__);
        return nullptr;
    }

    // 2. 进度回调设置 略

    // 在堆上创建 llama_model 实例,保存模型参数
    llama_model * model = new llama_model(params);

    // 3. 计算设备选择 (Device Selection)
    // 这一段决定了模型中的权重和计算将在哪些设备上(如 GPU、集成 GPU 或远程服务器)运行,而不是完全依赖 CPU。根据用户参数和系统环境,构建一个最优的计算设备列表(model->devices),用于模型权重和计算的分配。
    // 4. 单 GPU 模式调整 (Single GPU Mode Adjustment)
    // 如果是单设备模式 (LLAMA_SPLIT_MODE_NONE),则只保留主设备
    if (params.split_mode == LLAMA_SPLIT_MODE_NONE) {
        if (params.main_gpu < 0) {
            // main_gpu < 0 表示强制在 CPU 上运行
            model->devices.clear();
        } else {
            // 检查指定的 main_gpu 索引是否有效
            if (params.main_gpu >= (int)model->devices.size()) {
                LLAMA_LOG_ERROR("%s: invalid value for main_gpu: %d (available devices: %zu)\n", __func__, params.main_gpu, model->devices.size());
                llama_model_free(model);
                return nullptr;
            }
            // 仅保留指定的主 GPU
            ggml_backend_dev_t main_gpu = model->devices[params.main_gpu];
            model->devices.clear();
            model->devices.push_back(main_gpu);
        }
    }

    // 5. 实际模型加载 (Actual Model Loading)
    // 调用 llama.cpp 库的底层函数来执行实际的文件读取和权重加载
    const int status = llama_model_load(path_model, splits, *model, params);
    GGML_ASSERT(status <= 0); // 确认 status <= 0 (成功或取消/失败)
    
    // 检查加载状态,如果加载失败,释放已分配的 llama_model 内存
    // 并返回空指针
        // llama_model_free(model);
        // return nullptr;
    
    // 6. 返回load的结果
    return model;
}

可以看到, llama_model_load_from_file_impl 方法是确定运行的设备环境是否符合要求,除CPU之外,是否有GPU和远程设备可以使用。

实际的模型初始化加载函数为 llama_model_load ,其中有5个核心的加载步骤:

const int status = llama_model_load(path_model, splits, *model, params);

//    void load_stats  (llama_model_loader & ml);
//    void load_arch   (llama_model_loader & ml);
//    void load_hparams(llama_model_loader & ml);
//    void load_vocab  (llama_model_loader & ml);
//    bool load_tensors(llama_model_loader & ml);

如备注,其中会调用如下几个函数:

  • load_stats,读取模型的元数据统计信息。包括模型的创建时间、上次修改时间、版本号等非关键但有用的信息。在 GGUF 格式中,这些信息通常存储在头部或元数据区。
  • load_arch ,负责识别和设置模型的核心架构信息。它读取模型文件中的架构类型(如 LLaMA、Gemma、Mixtral 等),并设置 llama_model 结构体中的 archtype 字段,为后续的超参数和张量加载做准备。
  • load_hparams,负责加载模型的超参数 (Hyperparameters)。这些参数定义了模型的结构和大小,包括:层数 (n_layer)、嵌入维度 (n_embd)、注意力头数 (n_head)、上下文窗口大小 (n_ctx) 等。这些参数是构建模型计算图和分配 KV 缓存所必需的。
  • load_vocab , 负责加载模型的词汇表 (Vocabulary)。词汇表包含所有 Token 及其对应的 ID。这个步骤确保模型知道如何将输入的文本分词 (tokenize) 成数字 ID,以及如何将输出的数字 ID 转换回可读的文本。它填充了 llama_model 中的 vocab 结构体。
  • load_tensors,最关键的步骤。负责将模型的所有权重张量(如 tok_embd, wq, wk, wv, wo 等)从磁盘读取到内存或分配给选定的硬件设备(GPU)。这个过程通常涉及大量的数据传输和内存分配。它返回一个布尔值,用于指示加载是否被用户的进度回调函数取消。
数据预处理:添加系统提示和用户提示

模型加载完毕之后,我们可以单独提前加入系统prompt提示语:

// 存储聊天过程中用户和助手的消息列表
std::vector<llama_chat_message> _messages;

后续用户的聊天消息也会被添加进这个数组里,一起作为推理输入。

当用户输入一个请求,会在 startCompletion 函数中对所有的数据进行预处理,这个函数完成了所有开始推理前的准备工作,为后续的推理调用铺平了道路。使用 llama_chat_apply_template 将用户消息 (query) 格式化为 LLM 模型能够理解的、带有特殊标记(如 [INST] , <<SYS>> )的完整 Prompt 字符串。调用 common_tokenize 将格式化后的 Prompt 字符串转换成模型需要的数字 ID 序列(_promptTokens)。创建并填充 llama_batch 结构体,将 Token ID 序列和数量赋值给它。

void
LLMInference::startCompletion(const char *query) 

第一步会把最新的用户请求也添加进 _messages 数组中。

// 添加用户类型的prompt
addChatMessage(query, "user");

接着调用 llama_chat_apply_template 函数,将内部消息列表 (_messages) 格式化为模型可接受的 Prompt 字符串 (_formattedMessages)

int newLen = llama_chat_apply_template(_chatTemplate,       // 聊天模板句柄
                                           _messages.data(),    // 输入消息列表
                                           _messages.size(),    // 消息数量
                                           true,                // 强制添加 BOS(开始标记)
                                           _formattedMessages.data(), // 输出缓冲区
                                           _formattedMessages.size());// 输出缓冲区大小
    

然后会对这个 prompt 进行分词和解码。

std::string prompt(_formattedMessages.begin() + _prevLen, _formattedMessages.begin() + newLen);
_promptTokens = common_tokenize(llama_model_get_vocab(_model), prompt, true, true);

// create a llama_batch containing a single sequence
// see llama_batch_init for more details
_batch = new llama_batch();
_batch->token = _promptTokens.data();
_batch->n_tokens = _promptTokens.size();

一个序列的所有 Prompt 会被打包进一个 llama_batch ,其中只有最后一个 Tokenlogits 字段会被设为 true,以预测下一个 Token。以批量(Batch)的方式将一个或多个序列的 Token 输入给模型准备进行一次前向计算。

推理的触发

数据准备好之后,就可以循环调用 completionLoop 函数来进行对话补全推理:

/**
 * 循环获取 LLM 模型生成的响应片段。
 */
extern "C" JNIEXPORT jstring JNICALL
Java_com_stephen_llamacppbridge_LlamaCppBridge_completionLoop(JNIEnv* env, jobject thiz, jlong modelPtr) {
    // 将 jlong 类型的指针转换为 LLMInference 实例指针
    auto* llmInference = reinterpret_cast<LLMInference*>(modelPtr);
    try {
        // 调用 LLMInference 实例的 completionLoop 方法获取响应片段
        std::string response = llmInference->completionLoop();
        // 将 C++ 字符串转换为 Java 字符串并返回
        return env->NewStringUTF(response.c_str());
    } catch (std::runtime_error& error) {
        // 若生成过程中抛出异常,在 Java 层抛出 IllegalStateException 异常
        env->ThrowNew(env->FindClass("java/lang/IllegalStateException"), error.what());
        return nullptr;
    }
}

调用到 llama.cpp 框架的 completionLoop 函数,它负责在模型已经处理完初始 Prompt 之后,每调用一次就生成并处理下一个 Token

/**
 * 执行一次模型推理,采样下一个 Token,并处理输出。
 */
std::string
LLMInference::completionLoop() {
    // 1. 上下文大小检查

    // 获取模型的最大上下文大小
    uint32_t contextSize = llama_n_ctx(_ctx);
    
    // 获取当前 KV 缓存中已使用的位置(即已处理的 Token 数量)
    // llama_memory_seq_pos_max(..., 0) 获取序列 0 的最大位置
    _nCtxUsed = llama_memory_seq_pos_max(llama_get_memory(_ctx), 0) + 1;
    
    // 检查:当前已使用的上下文长度 + 批次中的 Token 数是否超过模型最大上下文
    // 如果超过,则抛出运行时错误,停止生成
    if (_nCtxUsed + _batch->n_tokens > contextSize) {
        throw std::runtime_error("context size reached");
    }

    // 2. 模型推理
    auto start = ggml_time_us(); // 计时开始
    
    // 运行模型解码:执行前向传播,计算当前批次中 Token 的 Logits
    // 此时 _batch 中应该只包含上一步采样出的新 Token,并且已设置好位置等信息。
    if (llama_decode(_ctx, *_batch) < 0) {
        throw std::runtime_error("llama_decode() failed"); // 解码失败
    }
    // 3. Token 采样和生成结束检查 (Sampling and EOG Check)
    // 从最新的 Logits 中采样下一个 Token ID
    // -1 表示使用批次中最后一个 Token 的 Logits 进行采样
    _currToken = llama_sampler_sample(_sampler, _ctx, -1);
    
    // 检查采样出的 Token 是否是 EOG (End of Generation) 标记
    if (llama_vocab_is_eog(llama_model_get_vocab(_model), _currToken)) {
        // 如果是 EOG,则将完整的回复添加到聊天记录中
        addChatMessage(strdup(_response.data()), "assistant");
        _response.clear();
        return "[EOG]"; // 返回特殊标记表示生成结束
    }
    
    // 将 Token ID 转换为可读的文本片段 (word-piece)
    std::string piece = common_token_to_piece(_ctx, _currToken, true);

    // 4. 性能记录和缓存 略
    ...

    // 5. 为下一轮循环准备
    // 重新初始化批次:为下一次 llama_decode 准备输入数据
    // 下一次解码只需要处理这一个新生成的 Token
    _batch->token = &_currToken; // 将批次的 Token 指针指向新生成的 Token ID
    _batch->n_tokens = 1;        // 设置批次中只有一个 Token

    // **注意:** 在下一个循环中,这个 `_batch` 中的 Token 将会被 `llama_decode` 处理,
    // 其位置信息等需要在使用前被更新 (通常由 llama_decode 内部处理或在一个辅助函数中完成)。
    // 
    // token有效性检查,检查缓存的 Token 片段是否是一个有效的 UTF-8 序列
    if (_isValidUtf8(_cacheResponseTokens.c_str())) {
        // 如果有效, 将有效片段添加到完整的回复中
        _response += _cacheResponseTokens;             
        // 拷贝有效片段用于返回
        std::string valid_utf8_piece = _cacheResponseTokens; 
        // 清空缓存,等待下一个 Token
        _cacheResponseTokens.clear();                  
        // 在这里返回完整的 UTF-8 文本片段
        return valid_utf8_piece;                       
    }

    // 如果无效,返回空字符串
    return "";
}

这个函数结合了 模型推理(llama_decode)、Token 采样(llama_sampler_sample)、生成停止检查和 UTF-8 编码处理 ,是实现流式输出的关键。

下一层核心的方法为 llama_decodellama_sampler_sample 函数。

llama_decode

llama_context::decode 是 llama.cpp 中负责执行模型前向传播(即推理)的核心函数。它将一个批次的输入 Token(存储在 llama_batch 中)转化为模型的输出(Logits 或嵌入向量),并同时管理模型的 KV 缓存。

/**
 * @brief 执行模型解码(前向传播)。将输入的 Token 批次通过模型进行计算。
 * @return 0 成功;-1 失败;-2 内存/计算错误;1 KV 缓存不足但已尝试优化;2 被取消。
 */
int llama_context::decode(const llama_batch & batch_inp)

这个函数可以大致分为以下几个核心阶段:

  • 输入验证和初始化: 检查输入批次是否有效,处理特殊情况。
  • KV 缓存管理: 核心步骤,决定如何将批次中的 Token 放入 KV 缓存。
  • 子批次循环 (UBatch Loop): 如果批次太大,将其分解为适合内存的小块进行处理。
  • 计算图构建与执行: 为每个子批次构建并执行模型计算图(Transformer Layers)。
  • 结果提取: 将计算结果(Logits 和/或嵌入向量)从设备内存异步传输回 CPU 内存。
  • 输出排序与映射: 确保输出结果的顺序与用户的输入顺序一致。
llama_sampler_sample

llama_sampler_sample 函数是 llama.cpp 中负责从模型输出中选出下一个 Token 的函数,即执行 Token 采样 的过程。

它的作用是将模型计算出的原始概率(Logits)转换为一个具体的、用于文本生成的新 Token ID。

这个函数可以分解为以下几个关键步骤:

llama_token llama_sampler_sample(struct llama_sampler * smpl, struct llama_context * ctx, int32_t idx) {
    // 1. 获取 Logits 和模型信息
    // 从上下文中获取指定索引位置的 Logits 数组
    const auto * logits = llama_get_logits_ith(ctx, idx);
    const llama_model * model = llama_get_model(ctx);
    const llama_vocab * vocab = llama_model_get_vocab(model);

    // 获取词汇表大小
    const int n_vocab = llama_vocab_n_tokens(vocab);
    // 2. 构建 Token 候选列表
    // 创建一个临时的 std::vector 用于存储所有 Token 的数据结构
    // TODO: 考虑优化,避免每次采样都重新分配内存
    std::vector<llama_token_data> cur;
    cur.reserve(n_vocab);
    // 遍历整个词汇表,将每个 Token ID 及其对应的 Logits 值打包成 llama_token_data 结构
    for (llama_token token_id = 0; token_id < n_vocab; token_id++) {
        // llama_token_data 结构体包含 ID, Logits 和概率 (prob,这里初始化为 0.0f)
        cur.emplace_back(llama_token_data{token_id, logits[token_id], 0.0f});
    }
    // 将 std::vector 包装成 llama_token_data_array 结构,这是采样链的标准输入格式
    llama_token_data_array cur_p = {
        /* .data       = */ cur.data(),  // 指向数据数组
        /* .size       = */ cur.size(),  // 数组大小 (词汇表大小)
        /* .selected   = */ -1,          // 初始化为 -1 (未选择)
        /* .sorted     = */ false,       // 尚未排序
    };
    // 3. 应用采样链
    // 调用核心采样函数:遍历 smpl 中配置的所有采样策略(如 Logits 惩罚、Top-K、Top-P、Temperature)
    // 这个函数会修改 cur_p.data 中的 Logits 值,并最终在 cur_p.selected 中标记选中的 Token 索引
    llama_sampler_apply(smpl, &cur_p);
    // 4. 提取和接受 Token
    // 断言检查:确保采样器已经成功选择了一个有效的 Token
    GGML_ASSERT(cur_p.selected >= 0 && cur_p.selected < (int32_t) cur_p.size);
    // 从选中的索引位置提取最终的 Token ID
    auto token = cur_p.data[cur_p.selected].id;
    // 通知采样器:这个 Token 已经被选中并使用。
    // 这允许采样器更新内部状态,例如:
    // - 更新上次生成的 Token 列表,以便在下一轮应用重复惩罚 (Repetition Penalty)。
    llama_sampler_accept(smpl, token);
    // 返回最终选出的 Token ID
    return token;
}
返回阶段性推理结果

模型的前向推理和采样完成之后,最后一步就是结合模型的词汇表。转换为可读的string字符串数据:

std::string common_token_to_piece(const struct llama_context * ctx, llama_token token, bool special) {
    const llama_model * model = llama_get_model(ctx);
    const llama_vocab * vocab = llama_model_get_vocab(model);
    return common_token_to_piece(vocab, token, special);
}

对于Java层,可以通过token数量,或者检测返回的token中是否有 “EOG” 字符串。即 End Of Generation 。当模型采样到一个被词汇表 (llama_vocab) 识别为 EOG 的 Token ID 时,意味着模型认为它已经完成了对用户 Prompt 的回答。

    // sample a token and check if it is an EOG (end of generation token)
    _currToken = llama_sampler_sample(_sampler, _ctx, -1);
    if (llama_vocab_is_eog(llama_model_get_vocab(_model), _currToken)) {
        // ... 返回 "[EOG]" 停止生成 ...
    }

运行效果

将这个模组直接封装成一个aar,也可以直接被其他模组依赖编译。

外部使用时,先将 .gguf 文件从手机下载路径复制到内部目录,也可以直接在线从 Hugging Face 上下载到本地内部目录。然后调用 loadModel()getResponseAsFlow() 等接口来加载模型,获取生成的对话回复。

运行结果如下,模型加载和对话回复:

打开实时的片段生成和性能追踪对比。推理过程的打印日志如下:

使用 Android StudioProfiler ,实时性能监控:

在加载模型是有一段巨大的爬升,将整个模型包括权重数据都对应读取到了 Native 堆中等待使用。在推理时可以看到CPU是程锯齿状一段一段地起伏,说明LLM正在执行一轮一轮的 自回归生成

多模态展望

在5个月前,llama.cpp已经启动多模态的集成开发,仅支持llama-server的模式启动,目前还没有尝试使用JNI的模式集成到app内,待有空了再研究下图像和音频的处理。

【AI】Understanding how LLM inference works with llama.cpp

【AI】Understanding how LLM inference works with llama.cpp

本文是llama.cpp项目的介绍,主要介绍了llama.cpp项目的运行流程和数据结构概念。

本文从原理上讲解了llama.cpp项目是如何运行LLM模型的,原文链接:

Understanding how LLM inference works with llama.cpp

Posted on: November 11, 2023 | at 04:00 PM (34 min read) llm ai llama llm-internals

In this post, we will dive into the internals of Large Language Models (LLMs) to gain a practical understanding of how they work. To aid us in this exploration, we will be using the source code of llama.cpp, a pure c++ implementation of Meta’s LLaMA model. Personally, I have found llama.cpp to be an excellent learning aid for understanding LLMs on a deeper level. Its code is clean, concise and straightforward, without involving excessive abstractions. We will use this commit version.

We will focus on the inference aspect of LLMs, meaning: how the already-trained model generates responses based on user prompts.

This post is written for engineers in fields other than ML and AI who are interested in better understanding LLMs. It focuses on the internals of an LLM from an engineering perspective, rather than an AI perspective. Therefore, it does not assume extensive knowledge in math or deep learning.

Throughout this post, we will go over the inference process from beginning to end, covering the following subjects (click to jump to the relevant section):

  • Tensors: A basic overview of how the mathematical operations are carried out using tensors, potentially offloaded to a GPU.
  • Tokenization: The process of splitting the user’s prompt into a list of tokens, which the LLM uses as its input.
  • Embedding: The process of converting the tokens into a vector representation.
  • The Transformer: The central part of the LLM architecture, responsible for the actual inference process. We will focus on the self-attention mechanism.
  • Sampling: The process of choosing the next predicted token. We will explore two sampling techniques.
  • The KV cache: A common optimization technique used to speed up inference in large prompts. We will explore a basic kv cache implementation.

By the end of this post you will hopefully gain an end-to-end understanding of how LLMs work. This will enable you to explore more advanced topics, some of which are detailed in the last section.

High-level flow from prompt to output

As a large language model, LLaMA works by taking an input text, the “prompt”, and predicting what the next tokens, or words, should be.

To illustrate this, we will use the first sentence from the Wikipedia article about Quantum Mechanics as an example. Our prompt is:

Quantum mechanics is a fundamental theory in physics that

The LLM attempts to continue the sentence according to what it was trained to believe is the most likely continuation. Using llama.cpp, we get the following continuation:

provides insights into how matter and energy behave at the atomic scale.

Let’s begin by examining the high-level flow of how this process works. At its core, an LLM only predicts a single token each time. The generation of a complete sentence (or more) is achieved by repeatedly applying the LLM model to the same prompt, with the previous output tokens appended to the prompt. This type of model is referred to as an autoregressive model. Thus, our focus will primarily be on the generation of a single token, as depicted in the high-level diagram below:

The full flow for generating a single token from a user prompt includes various stages such as tokenization, embedding, the Transformer neural network and sampling. These will be covered in this post.

Following the diagram, the flow is as follows:

  • The tokenizer splits the prompt into a list of tokens. Some words may be split into multiple tokens, based on the model’s vocabulary. Each token is represented by a unique number.
  • Each numerical token is converted into an embedding. An embedding is a vector of fixed size that represents the token in a way that is more efficient for the LLM to process. All the embeddings together form an embedding matrix.
  • The embedding matrix serves as the input to the Transformer. The Transformer is a neural network that acts as the core of the LLM. The Transformer consists of a chain of multiple layers. Each layer takes an input matrix and performs various mathematical operations on it using the model parameters, the most notable being the self-attention mechanism. The layer’s output is used as the next layer’s input.
  • A final neural network converts the output of the Transformer into logits. Each possible next token has a corresponding logit, which represents the probability that the token is the “correct” continuation of the sentence.
  • One of several sampling techniques is used to choose the next token from the list of logits.
  • The chosen token is returned as the output. To continue generating tokens, the chosen token is appended to the list of tokens from step (1), and the process is repeated. This can be continued until the desired number of tokens is generated, or the LLM emits a special end-of-stream (EOS) token.

In the following sections, we will delve into each of these steps in detail. But before doing that, we need to familiarize ourselves with tensors.

Understanding tensors with ggml

Tensors are the main data structure used for performing mathemetical operations in neural networks. llama.cpp uses ggml, a pure C++ implementation of tensors, equivalent to PyTorch or Tensorflow in the Python ecosystem. We will use ggml to get an understanding of how tensors operate.

A tensor represents a multi-dimensional array of numbers. A tensor may hold a single number, a vector (one-dimensional array), a matrix (two-dimensional array) or even three or four dimensional arrays. More than is not needed in practice.

It is important to distinguish between two types of tensors. There are tensors that hold actual data, containing a multi-dimensional array of numbers. On the other hand, there are tensors that only represent the result of a computation between one or more other tensors, and do not hold data until actually computed. We will explore this distinction soon.

Basic structure of a tensor

In ggml tensors are represented by the ggml_tensor struct. Simplified slightly for our purposes, it looks like the following:

// ggml.h
struct ggml_tensor {
    enum ggml_type    type;
    enum ggml_backend backend;

    int     n_dims;
    // number of elements
    int64_t ne[GGML_MAX_DIMS];
    // stride in bytes
    size_t  nb[GGML_MAX_DIMS];

    enum ggml_op op;

    struct ggml_tensor * src[GGML_MAX_SRC];

    void * data;

    char name[GGML_MAX_NAME];
};

The first few fields are straightforward:

  • type contains the primitive type of the tensor’s elements. For example, GGML_TYPE_F32 means that each element is a 32-bit floating point number.
  • enum contains whether the tensor is CPU-backed or GPU-backed. We’ll come back to this bit later.
  • n_dims is the number of dimensions, which may range from 1 to 4.
  • ne contains the number of elements in each dimension. ggml is row-major order, meaning that ne[0] marks the size of each row, ne[1] of each column and so on.

nb is a bit more sophisticated. It contains the stride: the number of bytes between consequetive elements in each dimension. In the first dimension this will be the size of the primitive element. In the second dimension it will be the row size times the size of an element, and so on. For example, for a 4x3x2 tensor:

An example tensor of 32-bit floating points with dimensions {4,3,2} and strides {4,16,48}.

The purpose of using a stride is to allow certain tensor operations to be performed without copying any data. For example, the transpose operation on a two-dimensional that turns rows into columns can be carried out by just flipping ne and nb and pointing to the same underlying data:

// ggml.c (the function was slightly simplified).
struct ggml_tensor * ggml_transpose(
        struct ggml_context * ctx,
        struct ggml_tensor  * a) {
    // Initialize `result` to point to the same data as `a`
    struct ggml_tensor * result = ggml_view_tensor(ctx, a);

    result->ne[0] = a->ne[1];
    result->ne[1] = a->ne[0];

    result->nb[0] = a->nb[1];
    result->nb[1] = a->nb[0];

    result->op   = GGML_OP_TRANSPOSE;
    result->src[0] = a;

    return result;
}

In the above function, result is a new tensor initialized to point to the same multi-dimensional array of numbers as the source tensor a. By exchanging the dimensions in ne and the strides in nb, it performs the transpose operation without copying any data.

Tensor operations and views

As mentioned before, some tensors hold data, while others represent the theoretical result of an operation between other tensors. Going back to struct ggml_tensor:

  • op may be any supported operation between tensors. Setting it to GGML_OP_NONE marks that the tensor holds data. Other values can mark an operation. For example, GGML_OP_MUL_MAT means that this tensor does not hold data, but only represents the result of matrix multiplication between two other tensors.
  • src is an array of pointers to the tensors between which the operation is to be taken. For example, if op == GGML_OP_MUL_MAT, then src will contain pointers to the two tensors to be multiplied. If op == GGML_OP_NONE, then src will be empty.
  • data points to the actual tensor’s data, or NULL if this tensor is an operation. It may also point to another tensor’s data, and then it’s known as a view. For example, in the ggml_transpose() function above, the resulting tensor is a view of the original, just with flipped dimensions and strides. data points to the same location in memory. The matrix multiplication function illustrates these concepts well:
// ggml.c (simplified and commented)
struct ggml_tensor * ggml_mul_mat(
        struct ggml_context * ctx,
        struct ggml_tensor  * a,
        struct ggml_tensor  * b) {
    // Check that the tensors' dimensions permit matrix multiplication.
    GGML_ASSERT(ggml_can_mul_mat(a, b));

    // Set the new tensor's dimensions
    // according to matrix multiplication rules.
    const int64_t ne[4] = { a->ne[1], b->ne[1], b->ne[2], b->ne[3] };
    // Allocate a new ggml_tensor.
    // No data is actually allocated except the wrapper struct.
    struct ggml_tensor * result = ggml_new_tensor(ctx, GGML_TYPE_F32, MAX(a->n_dims, b->n_dims), ne);

    // Set the operation and sources.
    result->op   = GGML_OP_MUL_MAT;
    result->src[0] = a;
    result->src[1] = b;

    return result;
}

In the above function, result does not contain any data. It is merely a representation of the theoretical result of multiplying a and b.

Computing tensors

The ggml_mul_mat() function above, or any other tensor operation, does not calculate anything but just prepares the tensors for the operation. A different way to look at it is that it builds up a computation graph where each tensor operation is a node, and the operation’s sources are the node’s children. In the matrix multiplication scenario, the graph has a parent node with operation GGML_OP_MUL_MAT, along with two children.

As a real example from llama.cpp, the following code implements the self-attention mechanism which is part of each Transformer layer and will be explored more in-depth later:

// llama.cpp
static struct ggml_cgraph * llm_build_llama(/* ... */) {
    // ...

    // K,Q,V are tensors initialized earlier
    struct ggml_tensor * KQ = ggml_mul_mat(ctx0, K, Q);
    // KQ_scale is a single-number tensor initialized earlier.
    struct ggml_tensor * KQ_scaled = ggml_scale_inplace(ctx0, KQ, KQ_scale);
    struct ggml_tensor * KQ_masked = ggml_diag_mask_inf_inplace(ctx0, KQ_scaled, n_past);
    struct ggml_tensor * KQ_soft_max = ggml_soft_max_inplace(ctx0, KQ_masked);
    struct ggml_tensor * KQV = ggml_mul_mat(ctx0, V, KQ_soft_max);

    // ...
}

The code is a series of tensor operations and builds a computation graph that is identical to the one described in the original Transformer paper:

In order to actually compute the result tensor (here it’s KQV) the following steps are taken:

  • Data is loaded into each leaf tensor’s data pointer. In the example the leaf tensors are K, Q and V.
  • The output tensor (KQV) is converted to a computation graph using ggml_build_forward(). This function is relatively straightforward and orders the nodes in a depth-first order.
  • The computation graph is run using ggml_graph_compute(), which runs ggml_compute_forward() on each node in a depth-first order. ggml_compute_forward() does the heavy lifting of calculations. It performs the mathetmatical operation and fills the tensor’s data pointer with the result.
  • At the end of this process, the output tensor’s data pointer points to the final result.

Offloading calculations to the GPU

Many tensor operations like matrix addition and multiplication can be calculated on a GPU much more efficiently due to its high parallelism. When a GPU is available, tensors can be marked with tensor->backend = GGML_BACKEND_GPU. In this case, ggml_compute_forward() will attempt to offload the calculation to the GPU. The GPU will perform the tensor operation, and the result will be stored on the GPU’s memory (and not in the data pointer).

Consider the self-attention omputation graph shown before. Assuming that K,Q,V are fixed tensors, the computation can be offloaded to the GPU:

The process begins by copying K,Q,V to the GPU memory. The CPU then drives the computation forward tensor-by-tensor, but the actual mathematical operation is offloaded to the GPU. When the last operation in the graph ends, the result tensor’s data is copied back from the GPU memory to the CPU memory.

Note: In a real transformer K,Q,V are not fixed and KQV is not the final output. More on that later.

With this understanding of tensors, we can go back to the flow of LLaMA.

Tokenization

The first step in inference is tokenization. Tokenization is the process of splitting the prompt into a list of shorter strings known as tokens. The tokens must be part of the model’s vocabulary, which is the list of tokens the LLM was trained on. LLaMA’s vocabulary, for example, consists of 32k tokens and is distributed as part of the model.

For our example prompt, the tokenization splits the prompt into eleven tokens (spaces are replaced with the special meta symbol ’▁’ (U+2581)):

Quantum▁mechanics▁is▁a▁fundamental▁theory▁in▁physics▁that

For tokenization, LLaMA uses the SentencePiece tokenizer with the byte-pair-encoding (BPE) algorithm. This tokenizer is interesting because it is subword-based, meaning that words may be represented by multiple tokens. In our prompt, for example, ‘Quantum’ is split into ‘Quant’ and ‘um’. During training, when the vocabulary is derived, the BPE algorithm ensures that common words are included in the vocabulary as a single token, while rare words are broken down into subwords. In the example above, the word ‘Quantum’ is not part of the vocabulary, but ‘Quant’ and ‘um’ are as two separate tokens. White spaces are not treated specially, and are included in the tokens themselves as the meta character if they are common enough.

Subword-based tokenization is powerful due to multiple reasons:

  • It allows the LLM to learn the meaning of rare words like ‘Quantum’ while keeping the vocabulary size relatively small by representing common suffixes and prefixes as separate tokens.
  • It learns language-specific features without employing language-specific tokenization schemes. Quoting from the BPE-encoding paper:

    consider compounds such as the German Abwasserbehandlungsanlange ‘sewage water treatment plant’, for which a segmented, variable-length representation is intuitively more appealing than encoding the word as a fixed-length vector.
  • Similarly, it is also useful in parsing code. For example, a variable named model_size will be tokenized into model|_|size, allowing the LLM to “understand” the purpose of the variable (yet another reason to give your variables indicative names!).

In llama.cpp, tokenization is performed using the llama_tokenize() function. This function takes the prompt string as input and returns a list of tokens, where each token is represented by an integer:

// llama.h
typedef int llama_token;
// common.h
std::vector<llama_token> llama_tokenize(
        struct llama_context * ctx,
        // the prompt
        const std::string & text,
        bool   add_bos);

The tokenization process starts by breaking down the prompt into single-character tokens. Then, it iteratively tries to merge each two consequetive tokens into a larger one, as long as the merged token is part of the vocabulary. This ensures that the resulting tokens are as large as possible. For our example prompt, the tokenization steps are as follows:

Q|u|a|n|t|u|m|▁|m|e|c|h|a|n|i|c|s|▁|i|s|▁a|▁|f|u|n|d|a|m|e|n|t|a|l|

Qu|an|t|um|▁m|e|ch|an|ic|s|▁|is|▁a|▁f|u|nd|am|en|t|al|

Qu|ant|um|▁me|chan|ics|▁is|▁a|▁f|und|am|ent|al|

Quant|um|▁mechan|ics|▁is|▁a|▁fund|ament|al|

Quant|um|▁mechan|ics|▁is|▁a|▁fund|amental|

Quant|um|▁mechan|ics|▁is|▁a|▁fundamental|

Note that each intermediate step consists of valid tokenization according to the model’s vocabulary. However, only the last one is used as the input to the LLM.

Embeddings

The tokens are used as input to LLaMA to predict the next token. The key function here is the llm_build_llama() function:

// llama.cpp (simplified)
static struct ggml_cgraph * llm_build_llama(
         llama_context & lctx,
     const llama_token * tokens,
                   int   n_tokens,
                   int   n_past);

This function takes a list of tokens represented by the tokens and n_tokens parameters as input. It then builds the full tensor computation graph of LLaMA, and returns it as a struct ggml_cgraph. No computation actually takes place at this stage. The n_past parameter, which is currently set to zero, can be ignored for now. We will revisit it later when discussing the kv cache.

Beside the tokens, the function makes use of the model weights, or model parameters. These are fixed tensors learned during the LLM training process and included as part of the model. These model parameters are pre-loaded into lctx before the inference begins.

We will now begin exploring the computation graph structure. The first part of this computation graph involves converting the tokens into embeddings.

An embedding is a fixed vector representation of each token that is more suitable for deep learning than pure integers, as it captures the semantic meaning of words. The size of this vector is the model dimension, which varies between models. In LLaMA-7B, for example, the model dimension is n_embd=4096.

The model parameters include a token-embedding matrix that converts tokens into embeddings. Since our vocabulary size is n_vocab=32000, this is a 32000 x 4096 matrix with each row containing the embedding vector for one token:

Each token has an associated embedding which was learned during training and is accessible as part of the token-embedding matrix.

The first part of the computation graph extracts the relevant rows from the token-embedding matrix for each token:

// llama.cpp (simplified)
static struct ggml_cgraph * llm_build_llama(/* ... */) {
    // ...

    struct ggml_tensor * inp_tokens = ggml_new_tensor_1d(ctx0, GGML_TYPE_I32, n_tokens);
    memcpy(
        inp_tokens->data,
        tokens,
        n_tokens * ggml_element_size(inp_tokens));

    inpL = ggml_get_rows(ctx0, model.tok_embeddings, inp_tokens);
}
//

The code first creates a new one-dimensional tensor of integers, called inp_tokens, to hold the numerical tokens. Then, it copies the token values into this tensor’s data pointer. Last, it creates a new GGML_OP_GET_ROWS tensor operation combining the token-embedding matrix model.tok_embeddings with our tokens.

This operation, when later computed, pulls rows from the embeddings matrix as shown in the diagram above to create a new n_tokens x n_embd matrix containing only the embeddings for our tokens in their original order:

The embedding process creates a fixed-size embedding vector for each of the original tokens. When stacked together they make up the embedding matrix of the prompt.

The Transformer

The main part of the computation graph is called the Transformer. The Transformer is a neural network architecture that is the core of the LLM, and performs the main inference logic. In the following section we will explore some key aspects of the transformer from an engineering perspective, focusing on the self-attention mechanism. If you want to gain an understanding of the intuition behind the Transformer’s architecture instead, I recommend reading The Illustrated Transformer2.

Self-attention

We first zoom in to look at what self-attention is; after which we will zoom back out to see how it fits within the overall Transformer architecture3.

Self-attention is a mechanism that takes a sequence of tokens and produces a compact vector representation of that sequence, taking into account the relationships between the tokens. It is the only place within the LLM architecture where the relationships between the tokens are computed. Therefore, it forms the core of language comprehension, which entails understanding word relationships. Since it involves cross-token computations, it is also the most interesting place from an engineering perspective, as the computations can grow quite large, especially for longer sequences.

The input to the self-attention mechanism is the n_tokens x n_embd embedding matrix, with each row, or vector, representing an indivisual token4. Each of these vectors is then transformed into three distinct vectors, called “key”, “query” and “value” vectors. The transformation is achieved by multiplying the embedding vector of each token with the fixed wk, wq and wv matrices, which are part of the model parameters:

Multiplying the embedding vector of a token with the wk, wq and wv parameter matrices produces a “key”, “query” and “value” vector for that token.

This process is repeated for every token, i.e. n_tokens times. Theoretically, this could be done in a loop but for efficiency all rows are transformed in a single operation using matrix multiplication, which does exactly that. The relevant code looks as follows:

// llama.cpp (simplified to remove use of cache)

// `cur` contains the input to the self-attention mechanism
struct ggml_tensor * K = ggml_mul_mat(ctx0,
    model.layers[il].wk, cur);
struct ggml_tensor * Q = ggml_mul_mat(ctx0,
    model.layers[il].wq, cur);
struct ggml_tensor * V = ggml_mul_mat(ctx0,
    model.layers[il].wv, cur);

We end up with K,Q and V: Three matrices, also of size n_tokens x n_embd, with the key, query and value vectors for each token stacked together.

The next step of self-attention involves multiplying the matrix Q, which contains the stacked query vectors, with the transpose of the matrix K, which contains the stacked key vectors. For those less familiar with matrix operations, this operation essentially calculates a joint score for each pair of query and key vectors. We will use the notation S(i,j) to denote the score of query i with key j.

This process yield n_tokens^2 scores, one for each query-key pair, packed within a single matrix called KQ. This matrix is subsequently masked to remove the entries above the diagonal:

A joint score S(i,j) is calculated for each query-key pair by multiplying Q with the transpose of K. The result shown here is for the first four tokens, along with the tokens represented by each score. The masking step ensures that only scores between a token and its preceding tokens are kept. An intermediate scaling operation has been omitted for simplicity.

The masking operation is a critical step. For each token it retains scores only with its preceeding tokens. During the training phase, this constraint ensures that the LLM learns to predict tokens based solely on past tokens, rather than future ones. Moreover, as we’ll explore in more detail later, it allows for significant optimizations when predicting future tokens.

The last step of self-attention involves multiplying the masked scoring KQ_masked with the value vectors from before5. Such a matrix multiplication operation creates a weighted sum of the value vectors of all preceeding tokens, where the weights are the scores S(i,j). For example, for the fourth token ics it creates a weighted sum of the value vectors of Quant, um, ▁mechan and ics with the weights S(3,0) to S(3,3), which themselves were calculated from the query vector of ics and all preceeding key vectors.

The KQV matrix contains weighted sums of the value vectors. For example, the highlighted last row is a weighted sum of the first four value vectors, with the weights being the highlighted scores.

The KQV matrix concludes the self-attention mechanism. The relevant code implementing self-attention was already presented before in the context of general tensor computations, but now you are better equipped fully understand it.

The layers of the Transformer

Self-attention is one of the components in what are called the layers of the transformer. Each layer, in addition to the self-attention mechanism, contains multiple other tensor operations, mostly matrix addition, multiplication and activation that are part of a feed-forward neural network. We will not explore these more in detail, but just note the following facts:

  • Large, fixed, parameter matrices are used in the feed-forward network. In LLaMA-7B, their sizes are n_embd x n_ff = 4096 x 11008.
  • Besides self-attention, all other operations can be thought of as being carried row-by-row, or token-by-token. As mentioned before, only self-attention contains cross-token calculations. This will be important later when discussing the kv-cache.
  • The input and output are always of size n_tokens x n_embd: One row for each token, each the size of the model’s dimension.

For completeness I included a diagram of a single Transformer layer in LLaMA-7B. Note that the exact architecture will most likely vary slightly in future models.

Full computation graph of a Transformer layer in LLaMA-7B, containing self-attention and feed-foward mechanisms. The output of each layer serves as the input to the next. Large parameter matrices are used both in the self-attention stage and in the feed-forward stage. These constitute most of the 7 billion parameters of the model.

In a Transformer architecture there are multiple layers. For example, in LLaMA-7B there are n_layers=32 layers. The layers are identical except that each has its own set of parameter matrices (e.g. its own wk, wq and wv matrices for the self-attention mechanism). The first layer’s input is the embedding matrix as described above. The first layer’s output is then used as the input to the second layer and so on. We can think of it as if each layer produces a list of embeddings, but each embedding no longer tied directly to a single token but rather to some kind of more complex understanding of token relationships.

Calculating the logits

The final step of the Transformer involves the computation of logits. A logit is a floating-point number that represents the probability that a particular token is the “correct” next token. The higher the value of the logit, the more likely it is that the corresponding token is the “correct” one.

The logits are calculated by multiplying the output of the last Transformer layer with a fixed n_embd x n_vocab parameter matrix (also called output in llama.cpp). This operation results in a logit for each token in our vocabulary. For example, in LLaMA, it results in n_vocab=32000 logits:

The final step of the Transformer computes the logits by multiplying the output of the last layer with a fixed parameter matrix (also called ‘output’). Only the last row of the result, highlighted here, is of interest, and contains a logit for each possible next token in the vocabulary.

The logits are the Transformer’s output and tell us what the most likely next tokens are. By this all the tensor computations are concluded. The following simplified and commented version of the llm_build_llama() function summarizes all steps which were described in this section:

// llama.cpp (simplified and commented)

static struct ggml_cgraph * llm_build_llama(
         llama_context & lctx,
     const llama_token * tokens,
                   int   n_tokens,
                   int   n_past) {
    ggml_cgraph * gf = ggml_new_graph(ctx0);
    struct ggml_tensor * cur;
    struct ggml_tensor * inpL;

    // Create a tensor to hold the tokens.
    struct ggml_tensor * inp_tokens = ggml_new_tensor_1d(ctx0, GGML_TYPE_I32, N);
    // Copy the tokens into the tensor
    memcpy(
        inp_tokens->data,
        tokens,
        n_tokens * ggml_element_size(inp_tokens));

    // Create the embedding matrix.
    inpL = ggml_get_rows(ctx0,
        model.tok_embeddings,
        inp_tokens);

    // Iteratively apply all layers.
    for (int il = 0; il < n_layer; ++il) {
        struct ggml_tensor * K = ggml_mul_mat(ctx0, model.layers[il].wk, cur);
        struct ggml_tensor * Q = ggml_mul_mat(ctx0, model.layers[il].wq, cur);
        struct ggml_tensor * V = ggml_mul_mat(ctx0, model.layers[il].wv, cur);

        struct ggml_tensor * KQ = ggml_mul_mat(ctx0, K, Q);
        struct ggml_tensor * KQ_scaled = ggml_scale_inplace(ctx0, KQ, KQ_scale);
        struct ggml_tensor * KQ_masked = ggml_diag_mask_inf_inplace(ctx0,
            KQ_scaled, n_past);
        struct ggml_tensor * KQ_soft_max = ggml_soft_max_inplace(ctx0, KQ_masked);
        struct ggml_tensor * KQV = ggml_mul_mat(ctx0, V, KQ_soft_max);

        // Run feed-forward network.
        // Produces `cur`.
        // ...

        // input for next layer
        inpL = cur;
    }

    cur = inpL;

    // Calculate logits from last layer's output.
    cur = ggml_mul_mat(ctx0, model.output, cur);

    // Build and return the computation graph.
    ggml_build_forward_expand(gf, cur);
    return gf;
}

To actually performn inference, the computation graph returned by this function is computed, using ggml_graph_compute() as described previously. The logits are then copied out from the last tensor’s data pointer into an array of floats, ready for the next step called sampling.

Sampling

With the list of logits in hand, the next step is to choose the next token based on them. This process is called sampling. There are multiple sampling methods available, suitable for different use cases. In this section we will cover two basic sampling methods, with more advanced sampling methods like grammar sampling reserved for future posts.

Greedy sampling

Greedy sampling is a straightforward approach that selects the token with the highest logit associated with it.

For our example prompt, the following tokens have the highest logits:

tokenlogit
▁describes18.990
▁provides17.871
▁explains17.403
▁de16.361
▁gives15.007

Therefore, greedy sampling will deterministically choose ▁describes as the next token. Greedy sampling is most useful when deterministic outputs are required when re-evaluating identical prompts.

Temperature sampling

Temperature sampling is probabilistic, meaning that the same prompt might produce different outputs when re-evaluated. It uses a parameter called temperature which is a floating-point value between 0 and 1 and affects the randomness of the result. The process goes as follows:

  • The logits are sorted from high to low and normalized using a softmax function to ensure that they all sum to 1. This transformation converts each logit into a probability.
  • A threshold (set to 0.95 by default) is applied, retaining only the top tokens such that their cumulative probability remains below the threshold. This step effectively removes low-probability tokens, preventing “bad” or “incorrect” tokens from being rarely sampled.
  • The remaining logits are divided by the temperature parameter and normalized again such that they all sum to 1 and represent probabilities.
  • A token is randomly sampled based on these probabilities. For example, in our prompt, the token ▁describes has a probability of p=0.6, meaning that it will be chosen approximately 60% of the time. Upon re-evaluation, different tokens may be chosen.

The temperature parameter in step 3 serves to either increase or decrease randomness. Lower temperature values suppress lower probability tokens, making it more likely that the same tokens will be chosen on re-evaluation. Therefore, lower temperature values decrease randomness. In contrast, higher temperature values tend to “flatten” the probability distribution, emphasizing lower probability tokens. This increases the likelihood that each re-evaluation will result in different tokens, increasing randomness.

Normalized next-token probabilities for our example prompt. Lower temperatures suppress low-probability tokens, while higher temperatures emphasize them. temp=0 is essentially identical to greedy sampling.

Sampling a token concludes a full iteration of the LLM. After the initial token is sampled, it is added to the list of tokens, and the entire process runs again. The output iteratively becomes the input to the LLM, increasing by one token each iteration.

Theoretically, subsequent iterations can be carried out identically. However, to address performance degradation as the list of tokens grows, certain optimizations are employed. These will be covered next.

Optimizing inference

The self-attention stage of the Transformer can become a performance bottleneck as the list of input tokens to the LLM grows. A longer list of tokens means that larger matrices are multiplied together. Each matrix multiplication consists of many smaller numerical operations, known as floating-point operations, which are constrained by the GPU’s floating-point-operations-per-second capacity (flops). In the Transformer Inference Arithmetic, it is calculated that for a 52B parameter model, on an A100 GPU, performance starts to degrade at 208 tokens due to excessive flops. The most commonly employed optimization technique to solve this bottleneck is known as the kv cache.

The KV cache

To recap, each token has an associated embedding vector, which is further transformed into key and value vectors by multiplying it with the parameter matrices wk and wv. The kv cache is a cache for these key and value vectors. By caching them, we save the floating point operations required for re-calculating them on each iteration.

The cache works as follows:

  • During the initial iteration, the key and value vectors are computed for all tokens, as previously described, and then saved into the kv cache.
  • In subsequent iterations, only the key and value vectors for the newest token need to be calculated. The cached k-v vectors, together with the k-v vectors for the new token, are concatenated together to form the K and V matrices. This saves recalculating the k-v vectors for all previous tokens, which can be significant.

On subsequent iterations, the key vector of the latest token only is calculated. The rest are pulled from the cache, and together they form the K matrix. The newly-computed key vector is also saved to the cache. The same process is applied to the value vectors.

The ability to utilize a cache for key and value vectors arises from the fact that these vectors remain identical between iterations. For example, if we first process four tokens, and then five tokens, with the initial four unchanged, then the first four key and value vectors will remain identical between the first and the second iteration. As a result, there’s no need to recalculate key and value vectors for the first four tokens in the second iteration.

This principle holds true for all layers within the Transformer, not just the first one. In all layers, the key and value vectors for each token are solely dependent on previous tokens. Therefore, as new tokens are appended in subsequent iterations, the key and value vectors for existing tokens remain the same.

For the first layer, this concept is relatively straightforward to verify: the key vector of a token is determined by multiplying the token’s fixed embedding with the fixed wk parameter matrix. Thus, it remains unchanged in subsequent iterations, regardless of the additional tokens introduced. The same rationale applies to the value vector.

For the second layer and beyond, this principle is a bit less obvious but still holds true. To understand why, consider the first layer’s KQV matrix, the output of the self-attention stage. Each row in the KQV matrix is a weighted sum that depends on:

  • Value vectors of previous tokens.
  • Scores calculated from key vectors of previous tokens.

Therefore each row in KQV solely relies on previous tokens. This matrix, following a few additional row-based operations, serves as the input to the second layer. This implies that the second layer’s input will remain unchanged in future iterations, except for the addition of new rows. Inductively, the same logic extends to the rest of the layers.

Another look at how the KQV matrix is calculated. The third row, highlighted, is determined based only on the third query vector and the first three key and value vectors, also highlighted. Subsequent tokens do not affect it. Therefore it will stay fixed in future iterations.

Further optimizing subsequent iterations

You might wonder why we don’t cache the query vectors as well, considering we cache the key and value vectors. The answer is that in fact, except for the query vector of the current token, query vectors for previous tokens are unnecessary in subsequent iterations. With the kv cache in place, we can actually feed the self-attention mechanism only with the latest token’s query vector. This query vector is multiplied with the cached K matrix to calculate the joint scores of the last token and all previous tokens. Then, it is multiplied with the cached V matrix to calculate only the latest row of the KQV matrix. In fact, across all layers, we now pass 1 x n_embd -sized vectors instead of the n_token x n_embd matrices calculated in the first iteration. To illustrate this, compare the following diagram, showing a later iteration, with the previous one:

Self-attention in subsequent iterations. In this example, there were four tokens in the first iteration and a fifth token, ‘▁is’, is added in the second iteration. The latest’s token key, query and value vectors, together with the cached key and value vectors, are used to compute the last row of KQV, which is all that is needed for predicting the next token.

This process repeats across all layers, utilizing each layer’s kv cache. As a result, the Transformer’s output in this case is a single vector of n_vocab logits predicting the next token.

With this optimization we save floating point operations of calculating unnecessary rows in KQ and KQV, which can become quite significant as the list of tokens grows in size.

The KV cache in practice

We can dive into llama.cpp code to see how the kv cache is implemented in practice. Unsurprisingly maybe, it is built using tensors, one for key vectors and one for value vectors:

// llama.cpp (simplified)

struct llama_kv_cache {
    // cache of key vectors
    struct ggml_tensor * k = NULL;

    // cache of value vectors
    struct ggml_tensor * v = NULL;

    int n; // number of tokens currently in the cache
};

When the cache is initialized, enough space is allocated to hold 512 key and value vectors for each layer:

// llama.cpp (simplified)
// n_ctx = 512 by default
static bool llama_kv_cache_init(
    struct llama_kv_cache & cache,
    ggml_type   wtype,
    int   n_ctx) {
    // Allocate enough elements to hold n_ctx vectors for each layer.
    const int64_t n_elements = n_embd*n_layer*n_ctx;

    cache.k = ggml_new_tensor_1d(cache.ctx, wtype, n_elements);
    cache.v = ggml_new_tensor_1d(cache.ctx, wtype, n_elements);

    // ...
}

Recall that during inference, the computation graph is built using the function llm_build_llama(). This function has a parameter called n_past that we ignored before. In the first iteration, the n_tokens parameter contains the number of tokens and n_past is set to 0. In subsequent iterations, n_tokens is set to 1 because only the latest token is processed, and n_past contains the number of past tokens. n_past is then used to pull the correct number of key and value vectors from the kv cache.

The relevant part from this function is shown here, utilizing the cache for calculating the K matrix. I simplified it slightly to ignore the multi-head attention and added comments for each step:

// llama.cpp (simplified and commented)

static struct ggml_cgraph * llm_build_llama(
    llama_context & lctx,
    const llama_token * tokens,
    int   n_tokens,
    int   n_past) {
    // ...

    // Iteratively apply all layers.
    for (int il = 0; il < n_layer; ++il) {
         // Compute the key vector of the latest token.
         struct ggml_tensor * Kcur = ggml_mul_mat(ctx0, model.layers[il].wk, cur);
         // Build a view of size n_embd into an empty slot in the cache.
         struct ggml_tensor * k = ggml_view_1d(
            ctx0,
            kv_cache.k,
            // size
            n_tokens*n_embd,
            // offset
            (ggml_element_size(kv_cache.k)*n_embd) * (il*n_ctx + n_past)
         );

         // Copy latest token's k vector into the empty cache slot.
         ggml_cpy(ctx0, Kcur, k);

         // Form the K matrix by taking a view of the cache.
         struct ggml_tensor * K =
             ggml_view_2d(ctx0,
                 kv_self.k,
                 // row size
                 n_embd,
                 // number of rows
                 n_past + n_tokens,
                 // stride
                 ggml_element_size(kv_self.k) * n_embd,
                 // cache offset
                 ggml_element_size(kv_self.k) * n_embd * n_ctx * il);
    }
}

First, the new key vector is calculated. Then, n_past is used to find the next empty slot in the cache, and the new key vector is copied there. Last, the matrix K is formed by taking a view into the cache with the correct number of tokens (n_past + n_tokens).

The kv cache is the basis for LLM inference optimization. It’s worth noting that the version implemented in llama.cpp (as of this writing) and presented here is not the most optimal one. For instance, it allocates a lot of memory in advance to hold the maximum number of key and value vectors supported (512 in this case). More advanced implementations, such as vLLM, aim to enhance memory usage efficiency and may offer further performance improvement. These advanced techniques are reserved for future posts. Moreover, as this field moves forward at lightning speeds, there are likely to be new and improved optimization techniques in the future.

Concluding

This post covered quite a lot of ground and should give you a basic understanding of the full process of LLM inference. With this knowledge you can get around more advanced resources:

  • LLM parameter counting and Transformer Inference Arithmetic analyze LLM performance in depth.
  • vLLM is a library for managing the kv cache memory more efficiently.
  • Continuous batching is an optimization technique to batch multiple LLM prompts together.

I also hope to cover the internals of more advanced topics in future posts. Some options include:

  • Quantized models.
  • Fine-tuned LLMs using LoRA.
  • Various attention mechanisms (Multi-head attention, Grouped-query attention and Sliding window attention).
  • LLM request batching.
  • Grammar sampling.

Stay tuned!

Footnotes

Footnotes

  1. ggml also provides ggml_build_backward() that computes gradients in a backward manner from output to input. This is used for backpropagation only during model training, and never in inference. ↩
  2. The article describes an encoder-decoder model. LLaMA is a decoder-only model, because it only predicts one token at a time. But the core concpets are the same. ↩
  3. For simplicity I have chosen to describe here a single-head self-attention mechanism. LLaMA uses a multi-head self-attention mechanism. Except for making the tensor operations a bit more complicated, it has no implications for the core ideas presented in this section. ↩
  4. To be precise, the embeddings first undergo a normalization operation that scales their values. We ignore it as it does not affect the core ideas presented. ↩
  5. The scores also undergo a softmax operation, which scales them so that each row of scores sums of to 1. ↩

【AI】LLM开发流程和运行环境简介

【AI】LLM开发流程和运行环境简介

本文介绍了LLM一般的开发流程和运行环境相关内容

接上文,了解了LLM的发展历程,从简单的线性回归模型到神经网络,再到生成式预训练转换器。

【AI】LLM大语言模型是如何工作的

现在更多从实际部署运行的操作上,介绍下LLM在各个平台上的运行。

LLM一般的开发流程

根据 OpenAI 联合创始人 Andrej Karpathy 在微软 Build 2023 大会上所公开的信息,OpenAI 所使用的大规模语言模型构建流程如下图所示。主要包含四个阶段:预训练、有监督微调、奖励建模、强化学习。这四个阶段都需要不同规模数据集合以及不同类型的算法,会产出不同类型的模型,同时所需要的资源也有非常大的差别。

1. 阶段一:数据准备

任何AI模型,要让神经网络输出的结果足够准确,必须以大量的数据来做训练,调节权重参数。数据的准备过程是整个流程的基石,数据的质量和规模直接决定了模型能力的上限。

首先需要构建一个规模庞大(通常是 PB 级别,即数千 TB)且多样化的数据集。来源包括网页数据、书籍、代码、专业文献、对话数据等。 然后要进行数据清洗。原始数据非常“脏”,必须进行严格清洗。例如要进行去重,移除完全相同或高度相似的文本,防止模型过度拟合这些重复内容。还有从质量上过滤,移除低质量文本,有害内容移除,过滤掉色情、暴力、极端言论等内容。最后还有隐私考量,移除个人身份信息。

数据集准备完成之后,要进行分词。计算机不认识“文字”,只认识数字。分词就是将文本转换成模型能理解的数字序列,它能有效地平衡词汇表大小和序列长度。

2. 阶段二:预训练 (Pre-training)

这是最耗时、最烧钱的阶段,目的是让模型掌握语言的“本质”。

现代 LLM 几乎都基于 Transformer 架构。最主流的是 Decoder-only (仅解码器) 架构(如 GPT 系列、Llama、PaLM),它非常擅长生成任务。

最常见的训练目标目标是 Next-Token Prediction 。简单来说,就是给模型一段文本,让它预测下一个最可能出现的词。

例如,输入 "The quick brown fox jumps over the",模型需要输出 "lazy"

通过在数万亿个 Token 上反复进行这个任务,模型可以学习语法、语义、事实知识、上下文理解甚至一定的推理能力。

同时,这一步需要极大规模的算力集群,通常由数千甚至上万块高端 GPU 组成,互联成一个超级计算机。训练时间可能长达数周甚至数月,成本高达数千万至数亿美元。

预训练结束后,我们得到一个 “基础模型” (Base Model)。这个模型博学,但“野性难驯”,它只会续写文本,不一定会听从指令(比如你让它“写首诗”,它可能会续写成“……是一个常见的作业要求”)。

3. 阶段三:对齐 (Alignment)

这是让模型从“文本续写机”变为“智能助手”的关键一步,目的是让模型的输出符合人类的意图 (Intent) 和价值观 (Values)。

这个阶段主要包含两个步骤:

1. 监督微调 (SFT)**

教会模型理解并执行指令。雇佣大量的数据标注员,编写高质量的“指令-回答”对(Prompt-Response pairs),利用这些少量高质量数据集合。

例如:

提示词(Prompt):复旦大学有几个校区?

理想输出:复旦大学现有 4 个校区,分别是邯郸校区、新江湾校区、枫林校区和张江校区。其中邯郸校区是复旦大学的主校区,邯郸校区与新江湾校区都位于杨浦区,枫林校区位于徐汇区,张江校区位于浦东新区。

利用这些有监督数据,使用与预训练阶段相同的语言模型训练算法,在基础语言模型基础上再进行训练,从而得到有监督微调模型(SFT 模型)。让它学会“对话”和“问答”的模式。

经过训练的 SFT 模型具备了初步的指令理解能力和上下文理解能力,能够完成开放领域问题、阅读理解、翻译、生成代码等能力,也具备了一定的对未知任务的泛化能力。

由于有监督微调阶段的所需的训练语料数量较少,SFT 模型的训练过程并不需要消耗非常大量的计算。根据模型的大小和训练数据量,通常只需要数十块GPU,花费数天时间完成训练。

SFT 模型具备了初步的任务完成能力,可以开放给用户使用,很多类 ChatGPT的模型都属于该类型,包括:Alpaca[38]Vicuna[39]MOSSChatGLM-6B 等。

很多这类模型效果也非常好,甚至在一些评测中达到了 ChatGPT 的 90% 的效果。当前的一些研究表明有监督微调阶段数据选择对 SFT 模型效果有非常大的影响,因此如何构造少量并且高质量的训练数据是本阶段有监督微调阶段的研究重点。

2. 偏好对齐 (RLHF)**

让模型的回答更有用、更诚实、更无害。主流技术是 RLHF (Reinforcement Learning from Human Feedback,基于人类反馈的强化学习)。主要有两步:

训练奖励模型 (Reward Model, RM)

奖励建模阶段目标是 构建一个文本质量对比模型 ,对于同一个提示词,SFT模型给出的多个不同输出结果的质量进行排序。

奖励模型(RM 模型)可以通过二分类模型,对输入的两个结果之间的优劣进行判断。RM 模型与基础语言模型和 SFT 模型不同,RM 模型本身并不能单独提供给用户使用。

奖励模型的训练通常和 SFT 模型一样,使用数十块 GPU,通过几天时间完成训练。由于 RM 模型的准确率对于强化学习阶段的效果有着至关重要的影响,因此对于该模型的训练通常需要大规模的训练数据。

Andrej Karpathy 在报告中指出,该部分需要百万量级的对比数据标注,而且其中很多标注需要花费非常长的时间才能完成。

如图,InstructGPT 系统中奖励模型训练样本标注示例。可以看到,示例中文本表达都较为流畅,标注其质量排序需要制定非常详细的规范,标注人员也需要非常认真的对标规范内容进行标注,需要消耗大量的人力,同时如何保持众包标注人员之间的一致性,也是奖励建模阶段需要解决的难点问题之一。

此外 奖励模型的泛化能力边界 也是在本阶段需要重点研究的另一个问题。如果 RM 模型的目标是针对所有提示词系统所生成输出都能够高质量的进行判断,该问题所面临的难度在某种程度上与文本生成等价,因此如何限定 RM 模型应用的泛化边界也是本阶段难点问题。

强化学习 (RL)

强化学习阶段根据数十万用户给出的提示词,利用在前一阶段训练的 RM 模型,给出 SFT 模型对用户提示词补全结果的质量评估,并与语言模型建模目标综合得到更好的效果。

该阶段所使用的提示词数量与有监督微调阶段类似,数量在十万量级,并且不需要人工提前给出该提示词所对应的理想回复。使用强化学习,在 SFT 模型基础上调整参数,使得最终生成的文本可以获得更高的奖励(Reward)。该阶段所需要的计算量相较预训练阶段也少很多,通常也仅需要数十块 GPU,经过数天时间的即可完成训练。

强化学习和有监督微调的对比,在模型参数量相同的情况下,强化学习可以得到相较于有监督微调好得多的效果。关于为什么强化学习相比有监督微调可以得到更好结果的问题,截止到 2023 年 9 月也还没有完整和得到普遍共识的解释。

此外,Andrej Karpathy 也指出强化学习也并不是没有问题的,它会使得基础模型的熵降低,从而减少了模型输出的多样性。在经过强化学习方法训练完成后的 RL 模型,就是最终提供给用户使用具有理解用户指令和上下文的类 ChatGPT 系统。

由于强化学习方法稳定性不高,并且超参数众多,使得模型收敛难度大,再叠加 RM 模型的准确率问题,使得在大规模语言模型如何能够有效应用强化学习非常困难。

DPO (Direct Preference Optimization) 是一种更新的、更简单的替代技术,它跳过了训练独立奖励模型的步骤,直接使用偏好数据来优化模型,效果也非常好。

4. 阶段四:评估与迭代 (Evaluation)

如何知道模型有多好?需要一套严格的“考试”体系。

  • 学术基准 (Academic Benchmarks):
    • 使用标准化的数据集来测试模型的各项能力。
    • MMLU: 综合能力测试(涵盖数学、历史、法律、计算机科学等57个科目)。
    • HumanEval / MBPP: 代码生成能力。
    • GSM8K: 小学数学应用题(测试推理)。
    • TruthfulQA: 评估模型是否会产生常见的错误信息或谎言。
  • 人类评估 (Human Evaluation):
    • 基准测试有其局限性(可能已被“污染”)。最终,模型的好坏还是需要人来主观判断。
    • 红队测试 (Red Teaming): 专门组织“攻击者” (Red Teamers) 尝试诱导模型说出有害、违规或错误的内容,以测试其安全性和鲁棒性。
  • 迭代 (Iteration):
    • 评估中发现的问题(如“数学不行”、“容易被诱导”)会反馈回前面的阶段,通过补充更高质量的数据、改进对齐算法等方式进行优化,然后重新训练和评估。

5. 阶段五:部署与推理

这是将模型交付给最终用户的阶段。

  • 模型优化 (Optimization):
    • 预训练出的模型非常庞大(动辄几百 GB),直接运行(推理)的成本高昂且速度慢。
    • 量化 (Quantization): 将模型的权重从高精度(如 32 位浮点数)降低到低精度(如 8 位或 4 位整数),极大减少显存占用和提高速度,同时尽量保持性能。
    • 蒸馏 (Distillation): 训练一个“小模型”来模仿“大模型”的行为。
    • 剪枝 (Pruning): 移除模型中不那么重要的参数。
  • 服务部署 (Serving):
    • 将优化后的模型部署到配备 GPU 的服务器上,并通过 API 接口对外提供服务。
    • 这需要解决高并发、低延迟等工程挑战,例如使用 vLLM、TensorRT-LLM 等推理框架。
  • 持续监控 (Monitoring):
    • 模型上线后,需要持续监控其表现,收集用户反馈和模型可能产生的“幻觉” (Hallucinations),用于下一轮的迭代优化。

开源社区

Huggingface

Hugging Face 是一家在人工智能(AI)领域迅速崛起的开源社区平台和公司,专注于让机器学习的构建和使用变得更加简单和开放。它最初是一家开发聊天机器人应用的公司,但后来转型并成为了全球AI领域,尤其是自然语言处理 (NLP) 领域的关键资源中心。

Hugging Face 的核心理念是 开放科学民主化AI,通过提供一系列工具、库和平台,使得研究人员和开发者能够轻松地共享、发现、训练和部署机器学习模型和数据集。

Hugging Face Hub 是一个类似 GitHub 的平台,但专门用于托管和共享机器学习模型、数据集和演示空间。

  • 模型共享:用户可以上传和下载各种预训练模型,每个模型都有详细的“模型卡片”介绍其用途、性能、局限性等。这促进了模型的可复用性和社区协作。
  • 数据集共享:Hub 上也托管了大量公开数据集,涵盖文本、图像、音频等多种格式,方便研究人员和开发者快速获取训练数据。
  • Gated Datasets:一些数据集可能需要申请权限才能访问,以确保数据合规性。
  • 版本控制:Hugging Face Hub 使用类似 Git 的版本控制系统,可以追踪模型的迭代和更新。

Hugging Face 对机器学习社区产生了深远的影响。通过提供开源工具和平台,它极大地促进了AI模型的开放共享和协作。降低AI了门槛,使得非专业人士也能更容易地使用和部署复杂的AI模型,推动了AI的普及。标准化了模型接口和共享机制,加速研究与开发,让研究人员可以更快地构建和迭代新模型,开发者可以更快地将研究成果转化为实际应用。Hugging Face 聚集了全球大量的AI研究者和开发者,共同推动AI技术的发展。

总的来说,Hugging Face 已经成为机器学习领域不可或缺的一部分,无论你是研究人员、开发者还是仅仅对AI感兴趣,它都提供了丰富的资源和工具来帮助你探索和构建AI应用。作为一名安卓开发者,Hugging Face 也能帮助你更便捷地将强大的AI能力集成到你的移动应用中。

魔塔

魔塔AI社区 是一个聚焦于人工智能(AI)技术交流与实践的线上社区平台,致力于为开发者、研究者、学生及AI爱好者提供技术分享、协作创新和资源整合的空间。

分主题板块(如模型架构设计、算法优化、行业应用案例),支持代码片段分享、论文解读和技术争议探讨。定期举办“技术擂台”或“挑战赛”(如模型压缩竞赛、数据集标注比赛),激发创新思维。

同时,提供代码托管功能(类似GitHub集成),支持成员发布AI项目、工具库或插件,并通过社区协作迭代优化。设立“明星项目”推荐板块,帮助优质项目获得曝光和贡献者。

还有学习与资源中心,整理AI领域的经典教材、课程笔记、工具链指南(如PyTorch/TensorFlow实战手册),并附有社区成员的批注与实战反馈。开设“新手村”板块,提供从环境配置到模型训练的逐步教程,降低入门门槛。

分享AI在医疗、金融、游戏等垂直行业的落地案例,讨论技术适配与商业化挑战。设立“内推直通车”和“技能树自测”功能,帮助成员对接企业需求,提升就业竞争力。

魔塔AI社区不仅是技术交流的场所,更是一个推动AI技术民主化、产业化的生态平台。无论是寻求答案的学习者、寻找协作的开发者,还是希望探索商业机会的创业者,都能在这里找到对应的价值支点。

模型架构 (Model Architecture)

目前,绝大多数大型 LLM 都基于Transformer (注意力机制) 架构。Transformer 是一种特殊的神经网络,它有两个主要部分:

  • 编码器 (Encoder): 负责理解输入文本的上下文信息。
  • 解码器 (Decoder): 负责根据编码器的理解生成输出文本。

Transformer 的关键是自注意力机制 (Self-Attention)。它允许模型在处理一个词时,同时考虑输入序列中所有其他词的重要性,从而捕捉词与词之间的长距离依赖关系。

Transformer 架构图示:

运行环境

为什么现在的大模型对GPU需求这么高?

大模型之所以对 GPU(图形处理器) 有着“饥渴”般的需求,根本原因在于它们的架构特性计算模式与 GPU 的设计理念完美契合。

CPU 就像一个拥有几名博士的团队,擅长攻克复杂的独立难题;而 GPU 更像一个拥有成千上万名小学生的大部队,虽然每个小学生只能做简单的加减法,但他们可以同时处理数百万个简单的加减法,效率惊人。大模型的计算任务,恰恰就是这种“数百万个简单加减法”的集合。

GPU更擅长海量的并行计算

大模型,特别是深度神经网络,在训练和推理过程中会进行天文数字般的数学运算,核心就是大量的矩阵乘法(Matrix Multiplication)向量运算(Vector Operations)

  • 特点:这些运算的特点是高度并行,即许多独立的、相似的计算可以同时进行。例如,一个矩阵的每个元素或一个向量的每个分量可以并行地进行计算,互不干扰。
  • GPU 的优势:GPU 内部拥有成百上千甚至上万个小型计算核心(流处理器),它们天生就是为并行计算而设计的。CPU 只有少数几个强大的核心,擅长处理复杂且串行的任务;而 GPU 则能以“人海战术”的方式,同时处理数百万个简单计算。这使得 GPU 在执行矩阵乘法这类并行任务时,比 CPU 快上数十甚至数百倍。

在神经网络中,每一层神经元从前一层接收输入,然后通过与权重矩阵相乘来计算其输出。这个过程就是矩阵乘法。 矩阵乘法计算:一个 m X nA 行列式,和一个 n X tB 行列式可以进行乘法运算得到一个 C 行列式。拿 A 的第 i 行(n个元素)和 B 的第 j 列(n个元素)元素乘积之和,就是 C 的第 [i,j] 位的元素值。

运算参数量巨大

大模型的“大”体现在其巨量的参数(Parameters)上。这些参数就是模型从训练数据中学习到的“知识”和“权重”。一个大模型可能拥有数十亿、数千亿甚至上万亿个参数。

每次进行前向传播(推理)或反向传播(训练)时,每个输入数据点都必须与模型的所有相关参数进行交互计算。参数量越大,需要进行的乘加运算就越多。这些参数通常以巨大的矩阵形式存储,GPU 高效的并行处理能力是处理如此大规模矩阵运算的关键。

包含大量的浮点运算

深度学习中的计算,尤其是权重和激活值的计算,通常使用浮点数(Floating-point Numbers)。大模型需要执行大量的浮点数乘法和加法。GPU 在设计时就高度优化了浮点运算单元。例如,NVIDIA 的 Tensor Cores 就是专门为深度学习中的混合精度计算(FP16/BF16)而设计的,能够极大地加速这些浮点运算。

高内存带宽和内存容量

大模型不仅参数量大,每次处理的数据批次(batch size)也可能很大,这意味着在计算过程中需要频繁地读取和写入大量数据。GPU 需要快速地从其显存中读取模型参数和输入数据,并将中间结果写回显存。现代 GPU 配备了高速的 GDDR 显存(如 GDDR6 或 HBM),拥有比 CPU 系统内存高得多的内存带宽,可以快速地吞吐大量数据。同时,高端 GPU 也提供了数十 GB 甚至上百 GB 的显存容量来存储庞大的模型参数。

专门的软件和硬件优化(CUDA 和 Tensor Cores)

NVIDIA 不仅仅提供了强大的 GPU 硬件,还开发了 CUDA 这样的并行计算平台,以及专门针对深度学习的 cuDNN 等库。

  • CUDA:它让开发者能够更容易地编写在 GPU 上高效运行的代码,连接了上层软件和底层硬件。
  • Tensor Cores:在最新的 NVIDIA GPU 中,集成了专门用于加速深度学习矩阵乘法的 Tensor Cores,它们能够以极高的效率执行低精度浮点运算,进一步提升了大模型训练和推理的速度。
CUDA 是什么?

CUDA(Compute Unified Device Architecture,统一计算设备架构)是 NVIDIA 公司开发的一种并行计算平台和编程模型。简单来说,它是一套让软件能够利用 NVIDIA 图形处理器(GPU) 强大计算能力的工具和接口。CUDA 的核心思想是让你能够编写代码,并将其中适合并行计算的部分“卸载”到 GPU 上执行。

它主要通过以下几个方面实现:

  1. 编程模型
    • CUDA 提供了一种基于 C、C++、Fortran 等传统语言的扩展,允许开发者编写被称为 “核函数”(Kernels) 的代码。
    • 核函数是一段在 GPU 上并行执行的代码。当你在 CPU(称为 Host)上调用一个核函数时,它会被分发到 GPU(称为 Device)上的成千上万个线程中并行执行。
    • 你需要将计算任务分解成小的、独立的子任务,每个子任务由一个 GPU 线程处理。
  2. GPU 硬件抽象
    • CUDA 提供了一套 API(应用程序编程接口),让开发者能够直接控制 GPU 的内存和计算资源。
    • 它抽象了 GPU 复杂的硬件细节,让程序员可以用相对更高层的方式来编写并行代码,而无需深入了解 GPU 内部的微架构。
  3. 内存管理
    • CPU 和 GPU 有各自独立的内存空间。在使用 CUDA 时,你需要将数据从 CPU 内存(Host Memory)传输到 GPU 内存(Device Memory),在 GPU 上进行计算,然后再将结果传回 CPU 内存。CUDA 提供了相应的 API 来管理这些数据传输。
  4. 工具套件
    • NVIDIA 提供了一个完整的 CUDA Toolkit,里面包含了编译器(如 nvcc,用于编译 CUDA C/C++ 代码)、库(如 cuDNN 用于深度学习,cuBLAS 用于线性代数)、调试工具、性能分析工具等,帮助开发者编写、优化和部署 GPU 加速的应用。

性能受限设备如何运行大模型

即使没有高性能的独立 GPU 的设备也可以运行大模型,但这通常会有一些重要的限制和权衡

CPU 也能执行并行计算 ,现代 CPU 通常有多个核心,并且支持 SIMD (Single Instruction, Multiple Data) 指令集(如 Intel 的 AVX、SSE 指令集),这使得它们能够同时处理少量数据。当没有 GPU 时,机器学习框架(如 TensorFlow 或 PyTorch)会自动回退到使用 CPU 来执行所有的矩阵乘法和其他计算。这些框架的 CPU 版本也会进行高度优化,以尽可能利用 CPU 的并行能力。

量化和剪枝是让大模型在资源受限设备上运行的关键技术。

  • 量化 (Quantization):将模型中原来用 32 位浮点数(FP32)表示的权重和激活值,转换为更低精度的格式,如 16 位浮点数(FP16/BF16)、8 位整数(INT8)甚至 4 位整数(INT4)。可以让 内存占用大幅减少 ,模型文件更小,加载更快。同时,低精度运算所需的计算资源更少,CPU 处理起来更快。
  • 剪枝 (Pruning):移除模型中不重要或冗余的连接和神经元,从而减小模型的大小和计算量。

这些优化技术可以在保持模型大部分性能的同时,显著降低其对计算资源(包括 CPU)的需求。

相比于大模型的训练,推理阶段的计算量相对较小训练大模型需要极其强大的 GPU,因为它涉及数万亿次的参数更新,需要多次迭代和反向传播。而在 推理(Inference)阶段,即模型用于实际预测时,只需要进行前向传播。虽然计算量依然庞大,但比训练时少得多。对于量化后的模型,推理的计算需求会进一步降低。

在没有 GPU 的电脑上运行大模型也会面临一些问题。

首先就是推理速度会比有 GPU 的系统慢很多,尤其是对于较大的模型和批次处理。其次,在这些设备上训练大型模型几乎不可能,因为 CPU 的计算能力和内存带宽远远不足。

在性能受限的设备上通常只能运行经过高度优化、量化甚至剪枝的较小版本的大模型,原始的巨型模型无法加载或运行。由于几乎所有的计算任务都需要 CPU 来完成,CPU 满负荷运行时能耗较高,可能导致设备发热严重。

LLM内部组件和运行流程

一个大模型(特指大型语言模型 LLM)在运行时,其进程内部包含多个核心组件协同工作,才能完成从接收输入到生成输出的全过程。这些组件涵盖了数据处理、模型计算和结果输出等多个环节。

一个典型的大模型运行时进程通常会包含以下核心组件:

1. Tokenizer (分词器)

大模型无法直接理解人类的自然语言文本。分词器是模型处理文本的第一步,它将输入的原始文本(如句子、段落)分解成模型能够理解的最小单位,这些单位称为 “token”

Token 可以是单词、子词(例如 “un”、”happy” 组成 “unhappy”),甚至是单个字符。

分词器还将每个 token 映射到一个唯一的整数 ID

  • 示例: 输入 “Hello, world!” 可能会被分词为 ["Hello", ",", " ", "world", "!"],并转换为对应的 ID 序列 [15496, 11, 220, 995, 0]

正确的分词对于模型理解输入和生成连贯的输出至关重要。不同的模型可能使用不同的分词器。

2. Model Core (模型核心 / 神经网络)

这个组件是大模型的心脏,负责执行实际的推理计算。它是一个由数千亿甚至上万亿个参数组成的深度神经网络(通常基于 Transformer 架构)。

核心接收分词器输出的 token ID 序列作为输入。将这些 ID 转换为词嵌入(Word Embeddings),这是一种高维向量表示,捕捉了 token 的语义信息。通过多层 Transformer 块进行复杂的数学运算(主要是矩阵乘法和激活函数),处理这些嵌入,捕捉文本中的上下文关系、语法结构和语义含义。最终输出每个位置上下一个 token 预测的概率分布

  • Embedding Layer(嵌入层): 将 token ID 转换为高维向量。
  • Transformer Blocks(Transformer 块): 包含多头自注意力机制 (Multi-Head Self-Attention) 和前馈网络 (Feed-Forward Network),负责学习和转换输入序列的表示。
  • Output Layer(输出层): 将最终的隐藏状态转换为词汇表中每个 token 的概率。

3. Generation Strategy / Decoding Algorithm (生成策略 / 解码算法)

模型核心输出的是下一个 token 的概率分布,而生成策略则根据这些概率来实际选择下一个 token,并决定何时停止生成。这好比模型给出了所有可能的字词及其概率,而生成策略就是选择最合理的那一个。常见的生成策略有:

  • 贪婪搜索 (Greedy Search): 每次都选择概率最高的 token。
  • 束搜索 (Beam Search): 同时跟踪多个最有可能的序列路径。
  • Top-K / Top-P (Nucleus Sampling): 从概率最高的 K 个 token 或累计概率达到 P 的 token 中随机选择,引入多样性。
  • 温度(Temperature): 调整概率分布的“锐利度”,影响生成文本的随机性和创造性。

不同的生成策略会显著影响模型输出的质量、多样性和流畅性

4. Context Management (上下文管理)

大模型在生成文本时需要记住之前的对话或输入内容,这就是上下文(Context)。上下文管理组件负责维护和更新模型当前处理的上下文。将新生成的 token 添加到现有上下文中。在上下文过长时(超过模型的上下文窗口限制),需要执行截断滑动窗口等策略来管理,确保模型始终在有效范围内工作。

5. Output Handler (输出处理器)

将模型生成的新 token ID 转换回人类可读的文本。通过分词器的逆向操作(detokenization)转换回字符串。同时可能还包括对输出文本的格式化、清理或后处理,例如删除多余的空格、处理特殊字符等。

6. System/Hardware Interface (系统/硬件接口)

这个组件不是模型本身的一部分,但它是大模型进程运行的底层基础。它负责将模型核心的计算任务调度到硬件加速器(如 GPU 或 NPU)上执行,并管理数据在内存和显存之间的传输。比如利用 CUDA、cuDNN (NVIDIA GPU) 或其他 AI 加速器 SDK (如 Android 设备的 NPU 驱动)。可以优化数据加载和计算流,以最大化硬件利用率。如果缺乏强大的硬件接口和底层优化,即使有优秀的模型,也无法高效运行。

运行流程概览:

  1. 用户输入:用户输入文本(例如 “帮我写一首诗关于秋天。”)。
  2. 分词器处理:分词器将输入文本转换为 token ID 序列。
  3. 模型核心计算:token ID 序列被送入模型核心。模型进行前向传播计算,输出词汇表中下一个可能 token 的概率分布。
  4. 生成策略选择:生成策略根据概率分布选择最合适的下一个 token ID。
  5. 上下文更新:新选出的 token ID 被添加到当前上下文。
  6. 循环迭代:重复步骤 3-5,直到达到停止条件(例如生成了完整句子、达到最大 token 数或生成了停止符)。
  7. 输出处理器:将最终生成的 token ID 序列转换回人类可读的文本输出。

Pagination