[{"content":"LoRA（Low-Rank Adaptation） 一种参数高效微调（Parameter-Efficient Fine-Tuning, PEFT）方式\n在冻结原始大模型参数的前提下，只训练一个很小的低秩权重增量\n原理 需要一点点基础的线性代数的知识\n假设某一层原始权重是：\n$$ W_0 \\in \\mathbb{R}^{d_{out} \\times d_{in}} $$\n输入是：\n$$ x \\in \\mathbb{R}^{d_{in}} $$\n原始线性层输出是：\n$$ y = W_0 x $$\n全量微调会直接更新整个 $W_0$，得到：\n$$ W_{new} = W_0 + \\Delta W $$\n这里的 $\\Delta W$ 就是微调过程对原模型权重造成的变化。\n而LoRA 不直接训练完整的 $\\Delta W$，而是把它写成两个小矩阵的乘积： $$ \\Delta W \\approx BA $$\n其中：\n$$ A \\in \\mathbb{R}^{r \\times d_{in}} $$\n$$ B \\in \\mathbb{R}^{d_{out} \\times r} $$\n并且：\n$$ r \\ll \\min(d_{in}, d_{out}) $$\n于是原来的线性层从：\n$$ y = W_0 x $$\n变成：\n$$ y = W_0 x + \\frac{\\alpha}{r}BAx $$\n这里：\n$W_0$：预训练模型原始权重，冻结不训练； $A$ 和 $B$：LoRA 新增的小矩阵，训练时只更新它们； $r$：rank，控制 LoRA 的容量； $\\alpha$：缩放系数，控制 LoRA 分支对原模型的影响强度； $\\frac{\\alpha}{r}$：常见实现里的归一化缩放。 一句话总结：\nLoRA 假设“微调需要的权重变化”是低秩的，所以只训练一个低秩增量，而不是训练整个大模型。\n所谓的低秩的假设就是说微调中重要的调整方向是较少的，因此就可以在限制r这一自由度的低秩矩阵基础上，达到类似的全量微调的效果\n详细讲就是：\n矩阵的秩可以粗略理解为：这个矩阵真正包含多少个独立方向。\n如果一个大矩阵的变化可以由少数几个方向组合出来，它就是低秩或近似低秩的。\n举一个线性层例子：\n$$ W_0 \\in \\mathbb{R}^{4096 \\times 4096} $$\n全量微调这一层需要训练：\n$$ 4096 \\times 4096 = 16,777,216 $$\n个参数。\n如果 LoRA 取：\n$$ r = 8 $$\n那么只训练：\n$$ A: 8 \\times 4096 $$\n$$ B: 4096 \\times 8 $$\n总参数量是：\n$$ 8 \\times 4096 + 4096 \\times 8 = 65,536 $$\n这一层的可训练参数约减少：\n$$ \\frac{16,777,216}{65,536} = 256 $$\n倍。\nLoRA 的训练过程 LoRA 训练时一般这样初始化：\n冻结 $W_0$； 随机初始化 $A$； 将 $B$ 初始化为 0。 因为开始时：\n$$ B = 0 $$\n所以：\n$$ BA = 0 $$\n模型初始输出仍然是：\n$$ y = W_0 x $$\n这意味着 LoRA 微调从原模型行为出发，而不是一开始就扰动模型。\n训练过程中，梯度只更新 $A$ 和 $B$：\nbase model W0: 冻结 LoRA A: 训练 LoRA B: 训练 最后得到一个任务专用的 adapter：\nLoRA adapter = A + B + 配置参数 这个 adapter 通常很小，可以单独保存、分享和加载。\nLoRA 的推理过程 训练完成后有两种使用方式。\n第一种是分离加载：\nbase model + LoRA adapter 推理时仍然计算：\n$$ y = W_0 x + \\frac{\\alpha}{r}BAx $$\n第二种是合并权重：\n$$ W_{merged} = W_0 + \\frac{\\alpha}{r}BA $$\n合并后推理就变成普通线性层：\n$$ y = W_{merged}x $$\n合并后几乎没有额外推理延迟。\nLoRA 加在哪里 Transformer里有大量线性层。LoRA 本质上可以加到任何线性层上，但在LLM里常见目标包括：\nattention 的 q_proj； attention 的 k_proj； attention 的 v_proj； attention 的 o_proj； MLP 的 gate_proj； MLP 的 up_proj； MLP 的 down_proj。 早期 LoRA 常只加在 attention 的 query 和 value 投影上，例如 q_proj、v_proj。现在很多指令微调实践会把 MLP 投影层也纳入目标模块。\n选择方式可以这样理解：\nLoRA 位置 优点 代价 只加 attention 的部分投影层 参数最省，训练快 表达能力较弱 加 attention 全部投影层 仍然比较省，效果更稳 参数略增 加 attention + MLP 更适合复杂任务和领域适配 显存、训练时间增加 加所有线性层 最接近全量微调 参数最多，也更可能过拟合 核心超参数 rank：r：上面说了\n缩放系数：lora_alpha：\nLoRA 实际使用的更新通常是：\n$$ \\Delta W = \\frac{\\alpha}{r}BA $$\n所以就是控制LoRA矩阵的影响力的\n常见设置会让 alpha 与 r 相等或是 r 的 2 倍\ndropout：lora_dropout：顾名思义是加在 LoRA 分支上的 dropout，dorpout是指训练中的一种防过拟合方法，训练时随机关闭一部分神经元或信号路径，让模型不要依赖单一特征，从而减少过拟合\n目标模块：target_modules：加在哪些层\n例如\n[\u0026#34;q_proj\u0026#34;, \u0026#34;k_proj\u0026#34;, \u0026#34;v_proj\u0026#34;, \u0026#34;o_proj\u0026#34;, \u0026#34;gate_proj\u0026#34;, \u0026#34;up_proj\u0026#34;, \u0026#34;down_proj\u0026#34;] 其他微调方式与比较 方法 更新什么 优点 缺点 全量微调 更新整个模型 效果上限高 显存、存储和部署成本高 Prompt Tuning 学习软提示 极省参数 表达能力有限 Prefix Tuning 学习前缀向量 省参数，适合生成任务 推理上下文开销更明显 Adapter 插入小模块 参数高效 推理路径通常增加额外计算 LoRA 学习低秩权重增量 参数少，可合并，推理友好 rank 太小会欠拟合 这里其他的还没太多了解\n变体：QLoRA、AdaLoRA、DoRA QLoRA\n可以理解成：\nQLoRA = 量化的冻结基座模型 + LoRA adapter 训练 普通 LoRA 减少的是可训练参数和优化器开销，但 base model 本身仍然要放进显存。\nQLoRA 进一步把冻结的 base model 量化t，从而显著降低显存占用。训练时，梯度通过量化后的模型传播，但实际更新的仍然是 LoRA adapter。\nAdaLoRA\n普通 LoRA 通常给不同层分配相同的 rank。\n但不同层的重要性并不一样。有的层对任务适配很关键，有的层几乎不需要动。\nAdaLoRA 的思想是：\n总 rank 预算固定，但动态分配给更重要的层和方向 它会根据重要性评分保留更有价值的低秩方向，剪掉不重要的方向。\nDoRA\n全称 Weight-Decomposed Low-Rank Adaptation。\n它认为权重可以拆成两个方面：\nmagnitude：权重大小； direction：权重方向。 普通 LoRA 主要通过低秩更新改变权重方向，但对 magnitude 的建模不够直接。\nDoRA 把 magnitude 和 direction 分开处理，用 LoRA 更专注地适配 direction，同时单独学习 magnitude。这样可以更接近全量微调的效果。\n","permalink":"https://caulif.com/posts/lora%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0/","summary":"整理 LoRA 的低秩适配原理、训练与推理流程、常见插入位置、核心超参数，以及 QLoRA、AdaLoRA、DoRA 等变体。","title":"LoRA 学习笔记"},{"content":" 仅是不成熟的思考和理解，对话也比较单薄，后续有时间可能会想看更复杂的会话数据或cli的会话数据（可能会和desktop版的不一样？），也可能分析和cc的区别，也可能看源码，但是现在先这样吧hh，想了解的东西太多了，感觉时间不太够\n认真读了会话数据，并在和gpt5.5的多次交互中，逐渐梳理清楚，有了大概的认知，我目前的理解是可以把会话数据拆成两条线来理解：\n模型上下文 / 发送返回线 主要看 response_item。它回答：模型实际收到了什么、输出了什么、请求了什么工具、工具结果如何回到模型上下文。\nRuntime / 事件状态线 主要看 session_meta、turn_context、event_msg、compacted。它回答：runtime 如何启动、调度、统计、压缩和恢复。\n下面我将送从头开始，列出一次查询天气对话+压缩+再发一个你好的会话数据流并简单分析 第一轮：用户让 Codex 查询北京天气 会话启动：写入 session_meta 会话运行现场快照 Runtime 线 { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:37:05.683Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;session_meta\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;id\u0026#34;: \u0026#34;019e6dba-b341-7701-96be-43a4435d0357\u0026#34;, \u0026#34;cwd\u0026#34;: \u0026#34;C:\\\\Users\\\\15893\\\\Documents\\\\test\u0026#34;, \u0026#34;originator\u0026#34;: \u0026#34;Codex Desktop\u0026#34;, \u0026#34;cli_version\u0026#34;: \u0026#34;0.133.0\u0026#34;, \u0026#34;source\u0026#34;: \u0026#34;vscode\u0026#34;, \u0026#34;thread_source\u0026#34;: \u0026#34;user\u0026#34;, \u0026#34;model_provider\u0026#34;: \u0026#34;custom\u0026#34;, \u0026#34;base_instructions\u0026#34;: \u0026#34;Codex 基础行为指令...\u0026#34;, \u0026#34;dynamic_tools\u0026#34;: \u0026#34;本轮可用工具 schema 列表...\u0026#34;, \u0026#34;git\u0026#34;: \u0026#34;当前 git 状态...\u0026#34; } } 会话启动消息 记录这次 session 的 ID、工作目录、来源、版本、工具、指令、Git 信息。\n其中base_instructions里面是很长的一段prompt，大体意思是规定了你是 Codex，如何处理代码任务，如何调用工具，如何做前端，如何编辑文件，如何回复用户，格式要求，最终回复要求，具体内容我这里就不放了\n值得注意的是，下面的所有response_item的role，都没有system的部分，我推测可能system的部分是内置在Responses API 服务器里面的，也可能这里的base_instructions就会被放进上下文的system/developer部分，有待进一步了解\n一轮任务开始：event_msg / task_started 建立 turn 生命周期边界 Runtime 线 { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:37:05.693Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;event_msg\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;task_started\u0026#34;, \u0026#34;turn_id\u0026#34;: \u0026#34;019e6dba-e4db-7022-96ef-2b2f5c4863a5\u0026#34;, \u0026#34;started_at\u0026#34;: 1779957425, \u0026#34;model_context_window\u0026#34;: 258400, \u0026#34;collaboration_mode_kind\u0026#34;: \u0026#34;default\u0026#34; } } 设计含义 这表示 runtime 开始处理一轮用户输入。\n对于turn这个概念，OpenAI 对 Codex 的抽象是：Thread -\u0026gt; Turn -\u0026gt; Item\nThread：持久会话，可创建、恢复、fork、归档。 Turn：用户输入触发的一轮 agent 工作。 Item：turn 里的原子输入/输出，例如 user message、agent message、tool execution、approval request、diff。 turn_id 标记了这轮turn的id给后面的完成、中断、耗时统计都会使用同一个id标识同一轮turn。\nmodel_context_window 记录当前模型上下文窗口，便于判断上下文压力。\n注入 developer 指令 权限、工具、skills、plugins 等运行配置 模型上下文线 { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:37:05.740Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;response_item\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;message\u0026#34;, \u0026#34;role\u0026#34;: \u0026#34;developer\u0026#34;, \u0026#34;content\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;\u0026lt;permissions instructions\u0026gt;...\u0026#34; }, { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;\u0026lt;app-context\u0026gt;...\u0026#34; }, { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;\u0026lt;collaboration_mode\u0026gt;...\u0026#34; }, { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;\u0026lt;skills_instructions\u0026gt;...\u0026#34; }, { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;\u0026lt;plugins_instructions\u0026gt;...\u0026#34; } ] } } developer prompt developer的prompt：包括权限、协作模式、skills、plugins、Codex Desktop 能力说明。 注入 AGENTS.md 与环境上下文 项目规则和当前环境 模型上下文线 { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:37:05.748Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;response_item\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;message\u0026#34;, \u0026#34;role\u0026#34;: \u0026#34;user\u0026#34;, \u0026#34;content\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;# AGENTS.md instructions for C:\\\\Users\\\\15893\\\\Documents\\\\test...\u0026#34; }, { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;\u0026lt;environment_context\u0026gt;cwd、shell、日期、时区...\u0026#34; } ] } } 项目级规则和一些环境信息。 turn_context：本轮上下文包边界 不是完整 prompt Runtime 线 { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:37:05.750Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;turn_context\u0026#34;, \u0026#34;payload\u0026#34;: {} } turn_context 是这一轮上下文包到这里组装好了的边界标记，runtime里面turn的标记。 用户原始输入：给模型看的版本 response_item / message / role=user 模型上下文线 { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:37:05.753Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;response_item\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;message\u0026#34;, \u0026#34;role\u0026#34;: \u0026#34;user\u0026#34;, \u0026#34;content\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;帮我搜索今天的北京天气\\n\u0026#34; } ] } } 第一次用户消息 用户提问的消息 用户原始输入：runtime 事件版本 event_msg / user_message Runtime 线 { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:37:05.753Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;event_msg\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;user_message\u0026#34;, \u0026#34;message\u0026#34;: \u0026#34;帮我搜索今天的北京天气\\n\u0026#34;, \u0026#34;images\u0026#34;: [], \u0026#34;local_images\u0026#34;: [], \u0026#34;text_elements\u0026#34;: [] } } 为什么同一句话出现两次 这一份属于 runtime 事件流。 它记录“用户消息事件发生了”，同时保留图片、本地图片、文本元素等输入形态。 所以它和上一条内容相似，但职责不同。 空 reasoning：runtime 事件版本 event_msg / agent_reasoning Runtime 线 { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:37:22.415Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;event_msg\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;agent_reasoning\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;\u0026#34; } } 这一条怎么看 它属于 runtime 事件线，表示 agent reasoning 相关事件出现了。 这里的 text 是空字符串，所以没有给用户展示任何推理摘要。 这类事件可以理解为 runtime 对“模型正在思考 / 推理阶段”的事件化记录。 空 reasoning：模型 item 版本 response_item / reasoning 模型上下文线 { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:37:22.421Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;response_item\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;reasoning\u0026#34;, \u0026#34;summary\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;summary_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;\u0026#34; } ], \u0026#34;content\u0026#34;: null, \u0026#34;encrypted_content\u0026#34;: \u0026#34;\u0026#34; } } 和上一条的区别 这条是模型上下文线里的 reasoning item。 summary 里有一个空的 summary_text，说明没有公开推理摘要。 content: null、encrypted_content: \u0026quot;\u0026quot; 表示这里也没有明文或加密推理内容。 模型请求调用工具：function_call 模型输出工具调用意图 模型上下文线 { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:37:24.114Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;response_item\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;function_call\u0026#34;, \u0026#34;name\u0026#34;: \u0026#34;shell_command\u0026#34;, \u0026#34;arguments\u0026#34;: \u0026#34;{ command: Get-Content 某个 SKILL.md, workdir: ... }\u0026#34;, \u0026#34;call_id\u0026#34;: \u0026#34;call_s8ed7YKvykhsf6GPqAhKMRqQ\u0026#34; } } 模型并没有直接执行命令 模型输出的是“我要调用 shell_command”的结构化意图。 真正执行命令的是 Codex runtime。 call_id 用来把请求和结果配对。 工具执行结果回填：function_call_output runtime 执行后把结果放回模型上下文 模型上下文线 { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:37:24.580Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;response_item\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;function_call_output\u0026#34;, \u0026#34;call_id\u0026#34;: \u0026#34;call_s8ed7YKvykhsf6GPqAhKMRqQ\u0026#34;, \u0026#34;output\u0026#34;: \u0026#34;Exit code: 0\\nWall time: 0.5 seconds\\nOutput:\\nSKILL.md 内容很长...\u0026#34; } } 为什么输出也进入 response_item 模型下一步要基于工具结果继续工作，所以结果必须回到模型上下文。 这条不是模型生成的自然语言回答，而是 runtime 给模型的工具结果。 加密 reasoning：模型保留的推理状态 response_item / reasoning / encrypted_content 模型上下文线 { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:37:48.809Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;response_item\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;reasoning\u0026#34;, \u0026#34;summary\u0026#34;: [], \u0026#34;content\u0026#34;: null, \u0026#34;encrypted_content\u0026#34;: \u0026#34;gAAAAABqF_7dG49MUwA5...很长的加密内容...\u0026#34; } } reasoning加密了 这里不给用户看的原始reasoning，而是加密后的 reasoning 状态。 关于这个reasoning加密，官方文档说的是reasoning models 会产生 reasoning tokens，模型用它们来思考、规划、比较方案、处理工具调用等。但这些 reasoning tokens “not visible via the API”。官方文档又说：使用 reasoning model 做 function calling 时，建议把前一次响应里的 reasoning items 一起传回下一次请求。这样模型能继续之前的推理过程，结果更好，也更省 token（命中缓存而且不用重新思考）。 既然有reasoning又要用又不想给用户看（怀疑是不想被蒸馏），那就只能加密了 Web 搜索结束：runtime 事件版本 event_msg / web_search_end Runtime 线 { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:37:53.517Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;event_msg\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;web_search_end\u0026#34;, \u0026#34;call_id\u0026#34;: \u0026#34;ws_0ed3b16bb031c342016a17feddd3c881919442335097dbc7ce\u0026#34;, \u0026#34;query\u0026#34;: \u0026#34;weather: Beijing, China\u0026#34;, \u0026#34;action\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;search\u0026#34;, \u0026#34;queries\u0026#34;: [\u0026#34;weather: Beijing, China\u0026#34;] } } } 这条记录搜索动作完成 它属于 runtime 事件线，说明一次 web search 已经结束。 call_id 用来标识这次搜索动作。 query 和 action.queries 记录了实际搜索的查询词。 Web 搜索调用：模型 item 版本 response_item / web_search_call 模型上下文线 { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:37:53.517Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;response_item\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;web_search_call\u0026#34;, \u0026#34;status\u0026#34;: \u0026#34;completed\u0026#34;, \u0026#34;action\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;search\u0026#34;, \u0026#34;queries\u0026#34;: [\u0026#34;weather: Beijing, China\u0026#34;] } } } 为什么搜索也有 response_item 这条属于模型上下文线，记录模型侧看到的搜索调用 item。 status: completed 表示这个搜索调用已经完成。 它和上一条 web_search_end 描述的是同一件事，但一个是 runtime 事件，一个是模型 item。 最终回答：runtime 事件版本 event_msg / agent_message Runtime 线 { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:37:55.609Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;event_msg\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;agent_message\u0026#34;, \u0026#34;message\u0026#34;: \u0026#34;北京今天（2026年5月28日）天气：晴，目前约 29°C...\u0026#34;, \u0026#34;phase\u0026#34;: \u0026#34;final_answer\u0026#34;, \u0026#34;memory_citation\u0026#34;: null } } 这一条表示什么 runtime 对外发布了一条助手消息事件。 phase: final_answer 表示这是最终回答阶段。 它适合被日志、状态系统、界面等上层消费者读取。 最终回答：模型输出版本 response_item / assistant message 模型上下文线 { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:37:55.615Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;response_item\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;message\u0026#34;, \u0026#34;role\u0026#34;: \u0026#34;assistant\u0026#34;, \u0026#34;content\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;output_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;北京今天（2026年5月28日）天气：晴，目前约 29°C...\u0026#34; } ], \u0026#34;phase\u0026#34;: \u0026#34;final_answer\u0026#34; } } 和上一条的差异 数据模型上下文线，也就是会进入上下文，role是assitant，代表是llm输出的内容。 上一条是 runtime 事件，这一条是模型输出 item。 任务完成：task_complete 和 task_started 配对 Runtime 线 { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:37:55.822Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;event_msg\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;task_complete\u0026#34;, \u0026#34;turn_id\u0026#34;: \u0026#34;019e6dba-e4db-7022-96ef-2b2f5c4863a5\u0026#34;, \u0026#34;last_agent_message\u0026#34;: \u0026#34;北京今天（2026年5月28日）天气...\u0026#34;, \u0026#34;completed_at\u0026#34;: 1779957475, \u0026#34;duration_ms\u0026#34;: 50449, \u0026#34;time_to_first_token_ms\u0026#34;: 15112 } } 它和 task_started 配对 标记这一轮 turn 完成。 记录最后一条助手消息，方便恢复和快速展示。 记录总耗时和首 token 延迟，用于性能分析。 压缩：上下文如何被整理成摘要-完成上一个对话之后我手动触发了一次压缩 下一轮任务开始：新的 task_started 压缩前后仍然是新的 turn Runtime 线 { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:50:14.269Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;event_msg\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;task_started\u0026#34;, \u0026#34;turn_id\u0026#34;: \u0026#34;019e6dc6-ee7b-7523-b672-1a9f099a65b6\u0026#34;, \u0026#34;started_at\u0026#34;: 1779958214, \u0026#34;model_context_window\u0026#34;: 258400, \u0026#34;collaboration_mode_kind\u0026#34;: \u0026#34;default\u0026#34; } } 这里说明什么 同一个 session 里可以有多个 turn。 这条表示 runtime 又开始处理一轮任务。 后面的 token 统计和 compacted 都发生在这轮上下文维护过程中。 第一次 token_count：记录累计和本轮 token 上下文压力和用量统计 Runtime 线 { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:50:57.012Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;event_msg\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;token_count\u0026#34;, \u0026#34;info\u0026#34;: { \u0026#34;total_token_usage\u0026#34;: { \u0026#34;input_tokens\u0026#34;: 30056, \u0026#34;cached_input_tokens\u0026#34;: 11648, \u0026#34;output_tokens\u0026#34;: 367, \u0026#34;reasoning_output_tokens\u0026#34;: 143, \u0026#34;total_tokens\u0026#34;: 30423 }, \u0026#34;last_token_usage\u0026#34;: { \u0026#34;input_tokens\u0026#34;: 18546, \u0026#34;cached_input_tokens\u0026#34;: 11648, \u0026#34;output_tokens\u0026#34;: 188, \u0026#34;reasoning_output_tokens\u0026#34;: 79, \u0026#34;total_tokens\u0026#34;: 18734 }, \u0026#34;model_context_window\u0026#34;: 258400 }, \u0026#34;rate_limits\u0026#34;: \u0026#34;当前速率限制状态...\u0026#34; } } 压缩前的第一次token count total_token_usage 是当前会话累计到这里的用量。 last_token_usage 是最近一次模型请求的用量。 cached_input_tokens 可以帮助理解哪些输入命中了缓存。 这些数据能辅助 runtime 判断是否需要压缩上下文。 第二次 token_count：压缩前的进一步统计 上下文继续增长 Runtime 线 { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:51:07.910Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;event_msg\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;token_count\u0026#34;, \u0026#34;info\u0026#34;: { \u0026#34;total_token_usage\u0026#34;: { \u0026#34;input_tokens\u0026#34;: 41472, \u0026#34;cached_input_tokens\u0026#34;: 16000, \u0026#34;output_tokens\u0026#34;: 917, \u0026#34;reasoning_output_tokens\u0026#34;: 290, \u0026#34;total_tokens\u0026#34;: 42389 }, \u0026#34;last_token_usage\u0026#34;: { \u0026#34;input_tokens\u0026#34;: 11416, \u0026#34;cached_input_tokens\u0026#34;: 4352, \u0026#34;output_tokens\u0026#34;: 550, \u0026#34;reasoning_output_tokens\u0026#34;: 147, \u0026#34;total_tokens\u0026#34;: 11966 }, \u0026#34;model_context_window\u0026#34;: 258400 }, \u0026#34;rate_limits\u0026#34;: \u0026#34;当前速率限制状态...\u0026#34; } } 为啥连着又有一次token count 这里可以看到累计 token 从三万多增长到四万多，那么新增的token是什么呢，我仔细分析了一下 last_token_usage也变了，而30056+11416=41472，同时5ms之后就写入了compacted，所有新增的一条消息应该是压缩请求，这里则进行了压缩完成前的一次统计， compacted：把旧历史替换成交接摘要 影响后续模型上下文，不删除本地日志 Runtime 线 { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:51:07.915Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;compacted\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;message\u0026#34;: \u0026#34;Another language model started... 交接摘要：天气查询已完成...\u0026#34;, \u0026#34;replacement_history\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;message\u0026#34;, \u0026#34;role\u0026#34;: \u0026#34;user\u0026#34;, \u0026#34;content\u0026#34;: \u0026#34;帮我搜索今天的北京天气\u0026#34; }, { \u0026#34;type\u0026#34;: \u0026#34;message\u0026#34;, \u0026#34;role\u0026#34;: \u0026#34;user\u0026#34;, \u0026#34;content\u0026#34;: \u0026#34;交接摘要文本...\u0026#34; } ] } } 压缩是如何实现的 首先本地日志不会因为压缩而删除前面的原始事件。\npayload.message = 压缩后生成的摘要正文\nreplacement_history = 被这次摘要替换掉的历史片段记录 / 替换关系记录\n所以压缩前后的过程应该是这样的\n压缩前： [真实用户消息] [developer / AGENTS / environment] [reasoning] [tool call] [tool output] [web search] [assistant answer] [各种历史 item] ↓ compacted 压缩后： [developer / AGENTS / environment 重新注入] [一条交接摘要 message] [新用户消息] 压缩后的下一轮上下文重新组装 压缩后重新注入 developer 指令 当前运行配置每轮都要重新提供 模型上下文线 { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:51:52.862Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;response_item\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;message\u0026#34;, \u0026#34;role\u0026#34;: \u0026#34;developer\u0026#34;, \u0026#34;content\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;\u0026lt;permissions instructions\u0026gt;...\u0026#34; }, { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;\u0026lt;app-context\u0026gt;...\u0026#34; }, { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;\u0026lt;collaboration_mode\u0026gt;...\u0026#34; }, { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;\u0026lt;skills_instructions\u0026gt;...\u0026#34; }, { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;\u0026lt;plugins_instructions\u0026gt;...\u0026#34; } ] } } 为什么压缩完又出现 压缩的是旧对话历史，不是当前运行配置。 权限、工具、skills、plugins 这些又被重新注入了上下文。 为什么不直接用之前的，可能考量是压缩后就要这么设计？清空原来的上下文，将摘要和不变的一些信息重新拼接上下文？ 压缩后重新注入 AGENTS.md 和环境上下文 项目规则和环境也会重新进入上下文 模型上下文线 { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:51:52.862Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;response_item\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;message\u0026#34;, \u0026#34;role\u0026#34;: \u0026#34;user\u0026#34;, \u0026#34;content\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;# AGENTS.md instructions for C:\\\\Users\\\\15893\\\\Documents\\\\test...\u0026#34; }, { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;\u0026lt;environment_context\u0026gt;cwd、shell、current_date、timezone...\u0026#34; } ] } } 为什么压缩后又有 AGENTS.md 同上，又重新注入，组装上下文了 第二轮：用户发“你好” 第二轮不用像第一轮那样每条都拆开，因为很多机制前面已经讲过了。这里更适合按阶段合并看：压缩后的上下文包、用户消息双线、运行现场重现、最后响应与收尾。\n压缩后新请求的上下文包 developer、AGENTS.md、environment_context 重新出现 模型上下文线 { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:51:52.862Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;response_item\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;message\u0026#34;, \u0026#34;role\u0026#34;: \u0026#34;developer\u0026#34;, \u0026#34;content\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;\u0026lt;permissions instructions\u0026gt;...\u0026#34; }, { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;\u0026lt;app-context\u0026gt;...\u0026#34; }, { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;\u0026lt;skills_instructions\u0026gt;...\u0026#34; }, { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;\u0026lt;plugins_instructions\u0026gt;...\u0026#34; } ] } } { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:51:52.862Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;response_item\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;message\u0026#34;, \u0026#34;role\u0026#34;: \u0026#34;user\u0026#34;, \u0026#34;content\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;# AGENTS.md instructions...\u0026#34; }, { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;\u0026lt;environment_context\u0026gt;...\u0026#34; } ] } } 这里不用重复细讲 第一轮已经讲过 developer 指令和 AGENTS.md 的作用。 有趣的是这里又重新注入了，或许每次对话都会重新注入？还是压缩完才这样，还没太搞明白 用户“你好”：模型上下文和 runtime 事件合并看 同一句消息仍然写两条 双线 { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:51:52.868Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;response_item\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;message\u0026#34;, \u0026#34;role\u0026#34;: \u0026#34;user\u0026#34;, \u0026#34;content\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;你好\\n\u0026#34; } ] } } { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:51:52.868Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;event_msg\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;user_message\u0026#34;, \u0026#34;message\u0026#34;: \u0026#34;你好\\n\u0026#34;, \u0026#34;images\u0026#34;: [], \u0026#34;local_images\u0026#34;: [], \u0026#34;text_elements\u0026#34;: [] } } 第一条是模型上下文线：真正给模型看的用户输入。 第二条是 Runtime 事件线：记录用户消息事件和输入形态。 这个模式第一轮已经详细讲过，所以第二轮只需要确认它仍然成立。 session_meta 再次出现：运行现场重现 不是新聊天内容，而是 runtime 快照 Runtime 线 { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:51:52.966Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;session_meta\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;id\u0026#34;: \u0026#34;019e6dba-b341-7701-96be-43a4435d0357\u0026#34;, \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:36:57.687Z\u0026#34;, \u0026#34;cwd\u0026#34;: \u0026#34;C:\\\\Users\\\\xxx\\\\Documents\\\\test\u0026#34;, \u0026#34;originator\u0026#34;: \u0026#34;Codex Desktop\u0026#34;, \u0026#34;base_instructions\u0026#34;: \u0026#34;完整 Codex 基础指令...\u0026#34;, \u0026#34;dynamic_tools\u0026#34;: [ { \u0026#34;namespace\u0026#34;: \u0026#34;codex_app\u0026#34;, \u0026#34;name\u0026#34;: \u0026#34;automation_update\u0026#34;, \u0026#34;inputSchema\u0026#34;: \u0026#34;...\u0026#34; }, { \u0026#34;namespace\u0026#34;: \u0026#34;codex_app\u0026#34;, \u0026#34;name\u0026#34;: \u0026#34;read_thread_terminal\u0026#34;, \u0026#34;inputSchema\u0026#34;: \u0026#34;...\u0026#34; }, { \u0026#34;namespace\u0026#34;: \u0026#34;codex_app\u0026#34;, \u0026#34;name\u0026#34;: \u0026#34;load_workspace_dependencies\u0026#34;, \u0026#34;inputSchema\u0026#34;: \u0026#34;...\u0026#34; } ] } } 这一条值得单独看一下 session_meta 这个又出现了一次，其实没有太清楚为什么这样设计，感觉一个session有一个session_meta不就行了 第二轮响应和收尾：合并看即可 assistant message、token_count、task_complete 双线 { \u0026#34;type\u0026#34;: \u0026#34;response_item\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;message\u0026#34;, \u0026#34;role\u0026#34;: \u0026#34;assistant\u0026#34;, \u0026#34;content\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;output_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;你好！...\u0026#34; } ], \u0026#34;phase\u0026#34;: \u0026#34;final_answer\u0026#34; } } { \u0026#34;type\u0026#34;: \u0026#34;event_msg\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;agent_message\u0026#34;, \u0026#34;message\u0026#34;: \u0026#34;你好！...\u0026#34;, \u0026#34;phase\u0026#34;: \u0026#34;final_answer\u0026#34; } } { \u0026#34;type\u0026#34;: \u0026#34;event_msg\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;token_count\u0026#34;, \u0026#34;info\u0026#34;: \u0026#34;本轮和累计 token 用量...\u0026#34; } } { \u0026#34;type\u0026#34;: \u0026#34;event_msg\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;task_complete\u0026#34;, \u0026#34;turn_id\u0026#34;: \u0026#34;第二轮 turn_id...\u0026#34;, \u0026#34;last_agent_message\u0026#34;: \u0026#34;你好！...\u0026#34;, \u0026#34;duration_ms\u0026#34;: \u0026#34;本轮耗时...\u0026#34; } } 这些就和之前一样了 assistant 回复仍然会有模型输出版本和 runtime 事件版本。 token_count 仍然记录本轮和累计用量。 task_complete 仍然和本轮 task_started 配对，标记 turn 结束。 这些机制第一轮已经讲过，第二轮只需要看到它们按同样模式继续运转。 最终整个会话的模型 用户输入 ↓ Codex runtime 创建 turn_id ↓ event_msg: task_started ↓ 组装 developer 指令、AGENTS.md、环境、历史摘要 ↓ turn_context 标记上下文包边界 ↓ response_item: user message event_msg: user_message ↓ 模型 reasoning / function_call ↓ runtime 执行工具 ↓ response_item: function_call_output ↓ response_item: assistant message event_msg: agent_message ↓ event_msg: token_count / task_complete ↓ 必要时 compacted，把旧历史替换成交接摘要 目前的结论：\n.jsonl 不是单纯聊天记录，而是一份完整的runtime trace。 response_item 主要负责模型上下文线：模型收到什么、输出什么、调用什么工具。 event_msg 主要负责 Runtime 事件线：turn 开始、用户事件、工具进度、token 统计、任务完成。 ","permalink":"https://caulif.com/posts/codex-session-json-trace/","summary":"阅读一次真实 Codex Desktop 会话 JSONL，按模型上下文线和 Runtime 事件线理解 Agent Loop 的运行细节。","title":"Agent 学习记录 1.5：Codex 会话记录分析"},{"content":"最近学习了一些 AI Agent 的相关知识，顺手整理成一篇学习笔记。\n我的阶段性理解是：Agent 的关键是它被放进了一个可以持续感知环境、调用工具、观察反馈、继续行动的循环系统里。\n[!NOTE] 这是“AI Agent 学习笔记”系列的第 1 篇，主要整理 Agent Loop 的基本概念。第 2 篇会继续看一次真实的 Codex Desktop 会话 JSONL 记录。\n什么是 AI Agent 我的理解是：\nAI Agent 是一个能围绕目标持续做事的系统。它读取上下文，决定下一步，调用工具，观察结果，再根据新结果继续调整，直到完成任务或需要人类介入。\n主要有几个部分：\n目标：Agent 通常不是只回答一个孤立问题，而是围绕某个任务持续推进。 上下文：Agent 需要知道当前环境、规则、历史对话、项目文件、工具说明等信息。 工具：Agent 可以调用搜索、终端、文件读写、浏览器、数据库等外部能力。 反馈：工具调用后会返回结果，Agent 会根据结果修正下一步。 循环：Agent 的行为不是一次性输出，而是多轮迭代。 所以，普通聊天更像：\n用户输入 -\u0026gt; 模型回答 而 Agent 更像：\n目标 -\u0026gt; 读取上下文 -\u0026gt; 决策 -\u0026gt; 调用工具 -\u0026gt; 观察结果 -\u0026gt; 继续决策 ReAct：推理和行动交织 Agent 的一个经典理论基础是 ReAct，对应论文是：ReAct: Synergizing Reasoning and Acting in Language Models。\nReAct 可以理解为把两个动作交织起来：\nReasoning：模型先判断现在知道什么、下一步应该做什么。 Acting：模型把判断转化成真实行动，例如调用工具、搜索资料、读取文件、运行命令。 它的最小形态常被概括成 T-A-O 循环：\nThought：我现在知道什么？下一步应该查什么或做什么？ Action：调用搜索、读取、点击、运行命令、编辑文件等工具。 Observation：工具或环境返回了什么？它是否支持我的判断？ Agent Loop：完整的运行循环 ReAct 描述的是推理和行动的基本单元。再往上看，就是完整的 Agent Loop，我觉得它增加了上下文组装、工具调度和停止条件等系统层面的东西。\n一个 Agent Loop 通常包含 6 个环节：\n目标输入：用户说要完成什么。 上下文组装：系统把任务、规则、历史、工具说明、相关文件、检索结果放进模型上下文。 模型决策：模型判断下一步应该做什么。 工具调用：模型通过工具读取文件、搜索资料、运行命令、修改内容。 观察结果：工具返回结果，模型读取这个结果。 继续或停止：模型决定继续下一轮，还是交付结果。 flowchart TD A[\u0026#34;任务输入\u0026#34;] --\u0026gt; B[\u0026#34;组装上下文\u0026#34;] B --\u0026gt; C[\u0026#34;模型决定下一步\u0026#34;] C --\u0026gt; D[\u0026#34;调用工具或直接回答\u0026#34;] D --\u0026gt; E[\u0026#34;观察结果\u0026#34;] E --\u0026gt; F{\u0026#34;是否完成？\u0026#34;} F -- \u0026#34;否\u0026#34; --\u0026gt; B F -- \u0026#34;是\u0026#34; --\u0026gt; G[\u0026#34;输出结果与验证信息\u0026#34;] Codex 中的 Agent Loop 为了加深理解，我又去看了 Codex 是如何实现 Agent Loop 的，因为平时用得比较多。下面这部分参考了 OpenAI 官方文章：深入解析 Codex 智能体循环。\n当 Codex 向 Responses API 发送请求时，它发送的不是一段纯文本，而是一个结构化的 JSON Payload。这个 Payload 主要包含三类信息：\ninstructions：系统指令，用来设定模型的基础规则、行为边界和协作方式。 tools：工具列表，告诉模型当前可以调用哪些外部能力。 input：输入内容列表，包含用户消息、历史上下文、工具结果、图片、文件信息等。 可以把它理解成：\nPayload ├── instructions：模型应该如何行动 ├── tools：模型可以调用什么能力 └── input：模型这次决策能看到什么上下文 其中 tools 可能包括：\nshell：允许模型在本地执行终端命令 update_plan：允许模型更新任务计划 web_search：允许模型检索公开网页信息 apply_patch：允许模型修改本地文件 如果说 instructions 决定了“应该怎么做”，input 决定了“当前知道什么”，那么 tools 就决定了“能对外部世界产生什么影响”。\n这三部分在 prompt 里的组织结构如下：\n图中的 Role 表示：在 Responses API 的输入里，每条信息都有自己的角色。模型并不是平等地看待所有文本，而是会按照角色优先级处理，我理解这种优先级应该是通过后训练实现的。\n常见优先级从高到低大致是：\nsystem：系统规则。 developer：开发者指令。 user：用户输入。 assistant：模型之前的回复。 对于 Codex 来说，在用户的真实输入进入模型之前，系统通常已经自动塞入了几层背景信息：\n沙盒和权限限制：告诉模型当前能不能改文件、能不能联网、什么时候需要用户授权。 开发者自定义指令：例如回复风格、工程习惯、验证要求等。 项目规范与技能：例如当前项目的 AGENTS.md、可用 Skills、代码风格、测试要求。 本地环境上下文：例如当前工作目录 cwd、终端类型、日期、时区等。 然后，用户的消息才被追加到输入列表后面，开启这一轮 Agent Loop。\n也就是说，当我们在 Codex 里输入一句“帮我改这个功能”时，模型实际看到的并不只有这句话，而是一个已经被系统装配好的任务环境。\n然后我去翻了翻自己某次 Codex 会话记录。\n具体的上下文堆叠应该是这样的：\n我的理解总结 学习到这里，我对 AI Agent 的理解可以总结成三句话：\nAgent = LLM + 上下文 + 工具 + 循环。 ReAct 解决的是模型如何把推理和行动交织起来。 Agent Loop 解决的是一个任务如何在环境反馈中持续推进。 ","permalink":"https://caulif.com/posts/ai-agent-loop/","summary":"从 AI Agent 的基本组成、ReAct 和 Codex Agent Loop 入手，整理我对 Agent 运行机制的阶段性理解。","title":"Agent 学习记录 01：Agent Loop"},{"content":"先放链接：\nhttps://github.com/caulif/page-annotator\n想法主要是增加tactus与页面的交互，现在主要是读取页面内容，所以想加上ai对页面的操作，想了想做标记比较实用\n现在设计了高亮和批注两种标记，暂时还没想好还需要加什么功能，一开始还有画圈和箭头功能，但是试了试感觉太丑了，而且不太实用，就删了。\n后续打算先完善一下兼容性的问题，然后再用几天看看有没有啥别的问题。\n今天拿gemini测了一下午，老出现未知错误，找了半天不知道哪出的问题，就是有标注，但是标注了几个之后就停了，然后返回位置错误。\n然后试了试claude sonnet 4.5，一下就好使了，评价是skill还得是用claude。\n今日测试结果如下： ","permalink":"https://caulif.com/posts/%E4%BB%8A%E5%A4%A9%E5%86%99%E4%BA%86%E4%B8%80%E4%B8%AAskill/","summary":"记录 page-annotator 的想法、标注交互设计和模型测试过程。","title":"今天写了一个skill"},{"content":"GGUF 与量化 前言：今天在看 GGUF，记录一下。之前用过几次 gguf 的模型，但是一直不理解啥意思，所以来看了看。\nPart 1: GGUF 文件二进制布局 先放一张官方图：\n上图是 gguf 文件的二进制布局。整体结构大概是： Header → Metadata block → Tensor Info array → Padding → Tensor Data\n1. Header（头部） 也就是图中左上角四个小黑条，包含以下四部分：\nMagic Number (4 bytes): GGUF。这是魔数，告诉计算机“这是一个 GGUF 文件”。对应的十六进制 0x47 0x47 0x55 0x46 正是 ASCII 码的 G-G-U-F。 Version (4 bytes): 版本号。图中显示 currently = 3（目前主流也是 v3）。 Tensor Count (8 bytes): 模型里有多少个张量（权重矩阵）。 Metadata KV Count (8 bytes): 有多少组元数据（键值对）。表示下面元数据部分有多少个键值对。 2. Metadata（元数据）- 绿色小条 里面有 Metadata KV Count 个 Key-Value 对。每个 KV 对都表示模型的某个配置信息。例如图中就有：\ngeneral.architecture: 模型架构（如 \u0026ldquo;llama\u0026rdquo;）。 llama.context_length: 上下文长度（如 4096）。 tokenizer.ggml.tokens: 分词器的词表。 3. Tensors Info（张量信息）- 紫色小条 权重的索引表，共有 Tensor Count 个。每个里面包含：\nname: 张量的名字（如 blk.0.ffn_gate.weight，即第0层的前馈网络权重）。\ndimensions: 形状（如 [4096, 32000]）。\ntype: 数据类型。支持各种量化格式（Q4_K, Q8_0, FP16 等），极大地压缩了体积。（看到这里发现对量化也一知半解）\noffset: 这是一个偏移量，表示当前 tensor 对应的实际数据在哪里，可以利用这个 offset 直接定位读取。\n据说这里的 offset 是相对 tensor_data 块起始位置的偏移量，但我没去看细节。\n4. Tensor Data（二进制张量数据）- 右侧黑色条 内容： 纯粹的二进制 0101\u0026hellip; 数据。 对齐（Alignment）： 为了让 CPU/GPU 读取更快，这些数据通常会按照 32 字节或 64 字节对齐（图中黑色条块中间的空隙就是 padding）。默认是 32 字节。 工作原理： 加载器读取了“第三部分”里的 offset 后，直接跳到这里对应的位置读取数据，或者直接 mmap 映射。 Part 2: 为什么 GGUF 能跨平台且支持混合推理？ 1. 内存映射与零拷贝 文件里的二进制数据布局，和 C/C++ 语言在内存里存数组的布局完全一致。所以可以用 mmap 进行内存映射，实现零拷贝加载。GPU offloading 需要显存拷贝，mmap 主要让 CPU 读得快。\n2. 对齐与 SIMD 友好 将张量数据切分成特定大小的 Block，这些块的大小（如 32 字节）正好是 CPU 寄存器一次能处理的数据宽度的倍数。\n在 constants.py 中，GGUF_DEFAULT_ALIGNMENT 被设置为 32 字节。 GGUFWriter 在写入张量数据前，会调用 ggml_pad 函数计算补齐长度。 write_padding 函数会在两个张量之间填充空字节。 3. 层级卸载 (Layer Offloading)\n原理：大模型是由一层一层的神经网络堆叠起来的（比如 Llama-2 有 32 层）。\nGGUF 的优势：因为 GGUF 里的张量是独立定义的（通过 Offset 查找），加载器（如 llama.cpp）可以灵活分配“谁干什么活”。\n大致实现流程：\n程序读取 GGUF 头部，发现有 32 层。\n用户设定 n_gpu_layers = 20（把前 20 层扔给显卡）。\n程序把 GGUF 文件中前 20 层的张量数据直接拷贝到 GPU 显存（VRAM）。（实际运行时不是整层移动，而是移动 tensor 级别，这样效果更好）\n懒得看源码了，但是找到了一篇帖子，感觉应该是两种方式都可行，但是 tensor 更好，后面要是用到我再去看看 https://www.reddit.com/r/LocalLLaMA/comments/1ki7tg7/dont_offload_gguf_layers_offload_tensors_200_gen/\n剩下的 12 层数据留在系统内存（RAM），由 CPU 计算。\n数据先在 GPU 跑完前 20 层，结果传回 CPU，CPU 跑完剩下 12 层。\n4. 针对 Mac 的优化 M 系列芯片是统一内存架构，CPU 和 GPU 共用同一块内存，GGUF 加载的数据，CPU 能看，GPU 也能看，几乎不需要数据拷贝。\nPart 3: 关于量化 1. 对称量化 例如假设有一层的权重，范围是从 -100.0 到 +100.0。 把它压缩成 8-bit 整数（范围 -128 到 127）。可以把 -100 映射成 -128，把 +100 映射成 +127，中间的数按比例缩放。\n问题： 因为权重可能是稀疏的，分布不均匀的，当大部分值比较小，少部分值很大时，精度损失很严重。所以有下面的分块量化。 2. 非对称量化 基本被弃用 非对称量化将浮点值范围 $[min, max]$ 映射到整数区间。这个范围不一定关于原点对称。\n核心逻辑：引入一个偏移量，使得浮点数中的 $0$ 可以对应整数中的非零值。 数学表示：$Q = \\text{round}(x / S + Z)$，其中 $Z$ 是零点偏移。 3. 分块量化 (Block Quantization) 其实就是分块进行量化。以下以对称量化为例： 假设我们有一排权重（32 个浮点数）：[0.12, -0.55, 1.20, ...]\n分组：把这 32 个数分成一组（Block）。 找极值：找出这组数里绝对值最大的数（比如 1.20）。 提取缩放因子 (Scale)：把 1.20 存下来（存为 16-bit 浮点数）。 压缩：把这 32 个数都除以 1.20，然后强行映射到 -8 到 +7 之间的整数（4-bit）。 存储： 硬盘上存：1 个缩放因子 + 32 个 4-bit 整数。 总大小：2 bytes (scale) + 16 bytes (data) = 18 bytes。 原大小（如果存 FP16）：32 * 2 bytes = 64 bytes。 压缩率：接近 1/4 计算时的解压缩：读取 4-bit 整数 → 乘以缩放因子 → 恢复成近似的浮点数 → 进行矩阵乘法。 Part 4: K-Quants (K-Points Quantization) 模型里不同的层，重要性是不一样的。\nv 向量（Attention Value）和 output 层非常敏感，所以要用高精度。 ffn 层（前馈网络）不敏感，可以选择低精度。 Q4_K_M、Q5_0 中的 K, M, 0 都是什么含义？\nQ：表示量化。 K：就是上面的 K-Quants。 后缀 S, M, L：感觉和衣服尺码差不多，表示不同比例的层是低精度的： _K_S: Small / 更紧压缩 + 更低 quality。 _K_M: Medium balance。 _K_L: Larger / 更高 quality。 具体策略主要是不同 block 划分 + scale estimation。 后缀 0：基础的分块 + 对称量化。 后缀 1：基础的分块 + 非对称量化。 Part 5: 去看了看现在的模型 (Kimi 2.5 \u0026amp; Qwen 3) Kimi-K2.5-GGUF unsloth/Kimi-K2.5-GGUF · Hugging Face 看了看刚出的 kimi2.5 的 gguf， 1-16bit 都有，虽然我都跑不了。\n新出现的术语：\nIQ1_S / IQ1_M (276 GB / 301 GB): I (Imatrix): 基于重要性矩阵 (Importance Matrix) 的量化。 相关链接 TQ1_0 (240 GB): T (Ternary): 三值量化。权重的值只能是 {-1, 0, 1} 或者是特定的三个数。 Q2_K_XL (375 GB): Q2: 2-bit 量化。 K: K-Quants（混合精度）。 XL: 又来一个后缀。普通的 llama.cpp 只有 S/M/L。这里的 XL 应该是特调的“超大杯”。 为什么叫 XL？ 对于 MoE 模型，专家数量很多。XL 可能意味着它保留了更多“共享专家”或关键层的精度，虽然叫 Q2，但实际体积比普通的 Q2 要大。 Qwen3 系列 Qwen3 - a Qwen Collection 又发现新词了：\n1. Thinking vs Instruct vs Base 据说是模仿 OpenAI o1 / DeepSeek-R1 的新分类。\nBase Model：原始模型，进行了预训练 Pre-training，能够预测下一个字，适用作为下游任务的底座（如在此基础上进行微调），或者用于单纯的文本补全任务。 Instruct / Chat Model：在 Base 模型的基础上，经过了监督微调（SFT）和人类反馈强化学习（RLHF），理解意图与遵循指令。它学会了“对话”的模式，知道当用户提问时，应该回答而不是续写。 Thinking / Reasoning Model：通过特殊的强化学习（RL）训练，学会了在输出最终答案前，先生成一段思维链（Chain of Thought, CoT）。自我感觉 thinking 确实聪明不少。 2. 命名规则：235B-A22B (MoE 架构) 235B (Total Params): 模型总共有 2350亿 个参数。硬盘占用极大，显存需求极大。 A22B (Activated Params): A 代表 Active。意思是当你问一个问题时，并不是 2350 亿个参数都在动，只有 220亿 个专家被激活参与计算。 Part 6: 其他量化格式 1. FP8 (Floating Point 8)\n例如：Qwen3-235B-A22B-Thinking-2507-FP8\nFP8 是原生浮点格式，会比 FP16 快，虽然精度低一些。 新显卡内置 FP8 计算单元，可以硬件加速，如 H100, RTX 4090 (Ada 架构)。 2. GPTQ (Generative Pre-trained Transformer Quantization)\n例如：Qwen3-235B-A22B-GPTQ-Int4\n技术原理: 逐行量化。它利用数学方法（Hessian 矩阵）来计算：“如果我把这个权重删减了，对输出误差影响大不大？”然后进行补偿。 特点: 静态: 需要一个“校准数据集”先跑一遍，一旦压好了，文件就定死了。 显卡友好: 在 NVIDIA 显卡上优化极好。 3. AWQ (Activation-aware Weight Quantization)\n例如：Qwen3-32B-AWQ\n把 FP16 压缩为 INT4 格式。 原理：在 AWQ 之前，很多量化方法只盯着**权重（Weights）**看。但 AWQ 作者发现：并不是数值大的权重才重要，重要性取决于“激活值（Activation）”。 模型中只有 0.1% - 1% 的权重是重要的 (Salient Weights)，它们对应的激活值非常大。只要保护好这 1% 的权重，整个模型的精度就能保住。 流程：与 GPTQ 相比不调整权重数值，只计算缩放系数： 观察：找一段校准数据喂给模型，看看哪些通道的激活值特别大。 放大：把这些对应的重要权重数值“放大”。 缩小：为了保证数学结果不变，同时把输入的激活值“缩小”。 量化：经过放大后的权重，在进行量化（四舍五入）时，相对误差会变小。 4. MLX\nMLX: 是苹果专门为 M1/M2/M3/M4 芯片写的原生代码库。它能深度调用苹果芯片的 NPU（神经网络引擎）。 ","permalink":"https://caulif.com/posts/gguf%E4%B8%8E%E9%87%8F%E5%8C%96/","summary":"从 GGUF 文件结构、内存映射、张量卸载和量化格式入手，整理本地大模型文件与推理优化的基础概念。","title":"GGUF与量化"},{"content":"校医院报销力度好大，每次去校医院都感觉在赚钱就是说。\n","permalink":"https://caulif.com/posts/%E4%BB%8A%E6%97%A5%E6%8B%94%E4%BA%86%E4%B8%80%E9%A2%97%E6%99%BA%E9%BD%BF/","summary":"记录一次拔智齿和校医院报销体验。","title":"今日拔了一颗智齿"},{"content":" 基于 llama2.c 项目的 run.c 源码注释整理\n写在开头 一直在关注llm的相关进展，但是感觉对Transformer一知半解，前几天在ai推荐下深入阅读了 Llama 2 的纯 C 语言实现（llama2.c），主要是通过写注释+查不懂的原理的方式理解这个代码，读完感觉对transformer清晰很多，大佬还是太强了，学习到了很多之前没有注意到的细节。\n本文由ai阅读我的注释后辅助撰写。\n源码链接如下：https://github.com/karpathy/llama2.c\n目录 写在开头 Transformer 核心概念 模型配置与数据结构 Transformer Block 位置编码：RoPE 前向传播流程 分词器：BPE 采样策略 推理循环 总结 Transformer 核心概念 整体架构 一个完整的大模型推理流程如下：\n用户输入 → 分词(Tokenizer) → 词嵌入(Embedding) → [Block 1 → Block 2 → ... → Block N] → 最终归一化 → 线性层投影 → Softmax 采样 → 输出 Token Decoder-Only 架构 在 Decoder-only 架构（如 GPT、Llama）中，所有的 Transformer Block 是完全线性串联的。每个 Block 的输出直接作为下一个 Block 的输入。\n模型配置与数据结构 Config：模型超参数 typedef struct { int dim; // 词嵌入向量的长度 int hidden_dim; // FFN 中间层的宽度 int n_layers; // Transformer Block 重复堆叠的次数 int n_heads; // Multi-Head Attention 中 Query 的头数 int n_kv_heads; // Key 和 Value 的头数（可以小于 n_heads） int vocab_size; // 词表大小 int seq_len; // 最大序列长度（上下文窗口） } Config; 参数解释：\ndim：词嵌入向量（Embedding）的长度。输入会先转化为 token，每个 token 映射为一个 dim 长度的向量。 hidden_dim：在 Transformer 每一层内部，Self-Attention 后面都跟着一个全连接层（FFN）。hidden_dim 就是这个中间层的宽度。模型先将 dim 扩展到 hidden_dim（通常是 2-4 倍），进行非线性变换，再压缩回 dim。这是模型存储\u0026quot;知识\u0026quot;的主要地方。 n_kv_heads：在现代模型中，KV 的头通常少于 Q 的头，实现压缩显存占用的目的，多个 Q 头共享同一个 KV 矩阵。 TransformerWeights：模型权重 typedef struct { float* token_embedding_table; // (vocab_size, dim) Token 嵌入表 float* rms_att_weight; // (layer, dim) Attention 前的归一化权重 float* rms_ffn_weight; // (layer, dim) FFN 前的归一化权重 float* wq; // (layer, dim, n_heads * head_size) Query 矩阵 float* wk; // (layer, dim, n_kv_heads * head_size) Key 矩阵 float* wv; // (layer, dim, n_kv_heads * head_size) Value 矩阵 float* wo; // (layer, n_heads * head_size, dim) 输出矩阵 float* w1; // (layer, hidden_dim, dim) FFN 升维矩阵（Gate） float* w2; // (layer, dim, hidden_dim) FFN 降维矩阵 float* w3; // (layer, hidden_dim, dim) FFN 升维矩阵（Up） float* rms_final_weight; // (dim,) 最终归一化权重 float* wcls; // 输出分类器（可能与 embedding 共享） } TransformerWeights; 权重共享： 在许多 Transformer 模型中，输入词嵌入层和输出分类层是共享权重的。如果 wcls 直接指向 token_embedding_table，可以节省大量显存。\nRunState：运行时激活值 typedef struct { float *x; // 当前激活值 (dim,) float *xb; // 残差分支缓冲区 (dim,) float *xb2; // 额外缓冲区 (dim,) float *hb; // FFN 隐藏层缓冲区 (hidden_dim,) float *hb2; // FFN 隐藏层缓冲区 (hidden_dim,) float *q; // Query (dim,) float *k; // Key (dim,) float *v; // Value (dim,) float *att; // 注意力分数 (n_heads, seq_len) float *logits; // 输出 logits float* key_cache; // KV 缓存 (layer, seq_len, dim) float* value_cache; // KV 缓存 (layer, seq_len, dim) } RunState; KV Cache 的重要性： 模型每产生一个新词，都要回看之前所有的词。为了不重复计算前面所有词的 K 和 V，我们将它们永久存在这两个\u0026quot;记忆库\u0026quot;中。\n显存占用示例： 如果一个模型的 dim=4096, n_layers=32, seq_len=4096，且不使用 GQA（即 kv_dim=4096）：\n单个 Cache 的大小 = 32 × 4096 × 4096 × 4 字节 ≈ 2 GB 两个 Cache 加起来就需要 4 GB 显存/内存 Transformer Block Transformer Block 由两个子层组成，每个子层都辅助有残差连接（Residual Connection）和层归一化（Layer Normalization）。\n1. Multi-Head Attention（多头自注意力层） 这是 Block 的第一个子层，负责捕捉序列中不同位置之间的依赖关系。\n简单理解：\n输入是\u0026quot;词典里的词\u0026quot; Attention 过程是\u0026quot;读句子的过程\u0026quot; 输出则是\u0026quot;放在句子里理解之后的词\u0026quot; 计算流程 步骤 1：线性投影\n输入的特征张量 $X \\in \\mathbb{R}^{seq_len \\times dim}$ 分别乘以三个权重矩阵 $W^Q, W^K, W^V$，生成查询（Query）、键（Key）和值（Value）矩阵。\n步骤 2：多头拆分\n将上面得到的三个矩阵的维度 $dim$ 拆分为 $n_{heads}$ 个子空间，每个头的维度为 $d_k = dim / n_{heads}$。\n每个 [Batch, Seq_Len, dim] 变成了 [Batch, Seq_Len, n_heads, d_k] 也就是说原来的 dim 长度的向量，切成了 n_heads 个 为了并行，对维度进行置换：\n[Batch, Seq_Len, n_heads, d_k] → [Batch, n_heads, Seq_Len, d_k] 仅交换位置，Batch 和 n_heads 看作是批处理维度，同时在 n_heads 个子空间内并行计算各自的注意力 步骤 3：注意力计算\n在每个头内部计算点积注意力，这里 $Q_i, K_i, V_i$（形状均为 [Seq_Len, d_k]）：\n$$ \\text{Attention}(Q, K, V) = \\text{softmax}\\left(\\frac{QK^T}{\\sqrt{d_k}}\\right)V $$\n详细分解：\n$QK^T$：[Seq_Len, d_k] × [d_k, Seq_Len] → [Seq_Len, Seq_Len]\n被称为注意力得分矩阵（Attention Score Matrix） 元素 $(i, j)$：代表序列中第 $i$ 个单词与第 $j$ 个单词之间的关联强度 缩放操作 $\\frac{1}{\\sqrt{d_k}}$：\n当维度 $d_k$ 非常大时，点积结果的方差会变得很大 假设 $Q$ 和 $K$ 的分量都是均值为 0、方差为 1 的独立随机变量，则 $QK^T$ 的点积均值为 0，方差为 $d_k$ 除以 $\\sqrt{d_k}$ 将方差重新缩放到 1，使梯度保持平稳 归一化处理 $\\text{softmax}(\\cdot)$：\n假设输入向量为 $\\mathbf{z} = [z_1, z_2, \\dots, z_n]$，Softmax 运算对其中每一个元素 $z_i$ 的计算公式如下： $$ \\text{Softmax}(z_i) = \\frac{e^{z_i}}{\\sum_{j=1}^{n} e^{z_j}} $$\n物理意义：将原始得分转化为概率分布，即所有的权重都变为正数且和为 1。这决定了在生成最终输出时，每个位置的信息应该贡献多少比例。 加权求和 $\\cdot V$：\n根据计算出的注意力权重，对 $V$ 向量进行线性加权 模型最终会选择性地从 $V$ 中提取出与当前 $Q$ 最相关的信息 步骤 4：多头拼接与投影\n将所有头的输出拼接回 $dim$ 维度，再通过一个线性投影矩阵 $W^O$ 进行映射。\n维度变化总结 假设输入序列长度为 $L$，特征维度为 $d$：\n输入映射：$Q(L \\times d_k), K(L \\times d_k), V(L \\times d_v)$ 得分矩阵 ($QK^T$)：$(L \\times d_k) \\times (d_k \\times L) = (L \\times L)$ Softmax 概率：$(L \\times L)$ 最终输出 ($\\text{Prob} \\times V$)：$(L \\times L) \\times (L \\times d_v) = (L \\times d_v)$ 2. Feed-Forward Network（前馈神经网络层） FFN 负责对每个词本身的特征进行深度加工和非线性变换。\n简单理解：\n输入是\u0026quot;初步理解了语境的词\u0026quot; FFN 过程是\u0026quot;对照脑海中的知识库进行深加工和逻辑验证的过程\u0026quot; 输出则是\u0026quot;最终定型、具备深层逻辑含义的知识表达\u0026quot; 公式 $$ \\text{FFN}(x) = \\text{Activation}(xW_1 + b_1)W_2 + b_2 $$\n其中：\n$x$：是该层的输入特征，形状为 [Seq_Len, dim] $W_1$：第一层线性变换的权重矩阵，形状为 [dim, hidden_dim] —— 升维 (Expansion) $W_2$：第二层线性变换的权重矩阵，形状为 [hidden_dim, dim] —— 降维 (Projection) $b_1, b_2$：偏置项（现代大模型如 Llama 往往会去掉偏置项以提高泛化能力） 激活函数 不同模型使用不同的激活函数：\nReLU（原始 Transformer）：$\\max(0, x)$\n简单高效，但存在神经元\u0026quot;坏死\u0026quot;的问题 GELU（BERT, GPT-3）：高斯误差线性单元\n在 0 附近更平滑，目前是中等规模模型的标准配置 SwiGLU（Llama, PaLM）：现代大模型的主流选择\n结合了 Swish 激活函数和门控线性单元（GLU） 计算方式： $$ \\text{SwiGLU}(x) = (\\text{Swish}(xW_1) \\otimes xW_3)W_2 $$\n其中 $\\text{Swish}(x) = x \\cdot \\sigma(x) = \\frac{x}{1 + e^{-x}}$\n这种设计增加了额外的参数，但显著提升了模型的收敛速度和最终表现 代码实现 // 升维 matmul(s-\u0026gt;hb, s-\u0026gt;xb, w-\u0026gt;w1 + l*dim*hidden_dim, dim, hidden_dim); matmul(s-\u0026gt;hb2, s-\u0026gt;xb, w-\u0026gt;w3 + l*dim*hidden_dim, dim, hidden_dim); // SwiGLU 激活 for (int i = 0; i \u0026lt; hidden_dim; i++) { float val = s-\u0026gt;hb[i]; // SiLU(x) = x * σ(x) val *= (1.0f / (1.0f + expf(-val))); // 逐元素乘以 w3(x) val *= s-\u0026gt;hb2[i]; s-\u0026gt;hb[i] = val; } // 降维 matmul(s-\u0026gt;xb, s-\u0026gt;hb, w-\u0026gt;w2 + l*dim*hidden_dim, hidden_dim, dim); 3. 残差连接与层归一化 残差连接（Residual Connection） $$ \\text{Output} = x + f(x) $$\n其中 $x$ 为输入，$f$ 为 Attention 或 FFN。\n物理意义：保留原始信息，防止在交流中\u0026quot;迷失自我\u0026quot;。\n层归一化（Layer Normalization） 将神经元的输出调整到一个合理的分布范围（通常是均值为 0，方差为 1）。\n$$ \\text{LN}(x_i) = \\gamma \\cdot \\hat{x}_i + \\beta $$\n其中：\n$$ \\hat{x}_i = \\frac{x_i - \\mu}{\\sqrt{\\sigma^2 + \\epsilon}} $$\n$\\mathbf{x}$：输入向量，在 Transformer 中其维度即为 dim（如 512 或 4096） $\\mu$：均值 $\\sigma^2$：方差 $\\epsilon$：数值稳定性项，确保分母不为零 $\\gamma$（Gamma）：维度与 dim 一致的向量，初始化通常为全 1。模型通过训练来调整每一维特征的缩放比例 $\\beta$（Beta）：维度与 dim 一致的向量，初始化通常为全 0。模型通过训练来调整每一维特征的基准偏移 RMSNorm（Llama 使用） RMSNorm（Root Mean Square Layer Normalization，均方根归一化）是 LayerNorm 的简化版本：\n计算均方根： $$ \\text{RMS}(x) = \\sqrt{\\frac{1}{n} \\sum_{i=1}^{n} x_i^2 + \\epsilon} $$\n归一化： $$ \\bar{x}_i = \\frac{x_i}{\\text{RMS}(x)} $$\n缩放： $$ y_i = \\bar{x}_i \\cdot \\gamma_i $$\n代码实现：\nvoid rmsnorm(float* o, float* x, float* weight, int size) { // 计算平方和 float ss = 0.0f; for (int j = 0; j \u0026lt; size; j++) { ss += x[j] * x[j]; } ss /= size; ss += 1e-5f; // epsilon ss = 1.0f / sqrtf(ss); // 归一化并缩放 for (int j = 0; j \u0026lt; size; j++) { o[j] = weight[j] * (ss * x[j]); } } 结构组装流水线（Pre-Norm） 一个 Transformer Block 的完整流程（以 Pre-Norm 为例）：\n第一层归一化（LayerNorm 1）：对输入向量进行标准化，平滑数值 多头自注意力（Attention）：向量在这里进行\u0026quot;横向交流\u0026quot;，获取上下文 第一层残差相加（Residual Add）：将 Attention 的输出与最原始的输入直接相加 第二层归一化（LayerNorm 2）：再次标准化，为深度计算做准备 前馈神经网络（FFN）：向量在这里进行\u0026quot;纵向深挖\u0026quot;，对照知识库提取语义 第二层残差相加（Residual Add）：将 FFN 的输出与进入 FFN 前的状态相加 数学表达： $$ x_1 = x + \\text{Attention}(\\text{LayerNorm}_1(x)) $$\n$$ x_{out} = x_1 + \\text{FFN}(\\text{LayerNorm}_2(x_1)) $$\n位置编码：RoPE 为什么需要位置编码？ Attention 机制本身是位置无关的。如果不加位置信息，模型无法区分\u0026quot;我爱你\u0026quot;和\u0026quot;你爱我\u0026quot;。\nRoPE（Rotary Positional Embedding） RoPE 通过在向量空间中旋转 Q 和 K，使得它们的点积自动包含相对位置信息。\n核心思想：如果这个词处于句子的第 $m$ 个位置，我们就把这个点绕原点旋转 $m \\cdot \\theta$ 角度。\n数学推导 假设我们有一个二维向量 $\\mathbf{x} = [x_1, x_2]$，它处于位置 $m$。我们定义一个旋转矩阵 $\\mathbf{R}_m$：\n$$ \\mathbf{R}_m = \\begin{pmatrix} \\cos(m\\theta) \u0026amp; -\\sin(m\\theta) \\ \\sin(m\\theta) \u0026amp; \\cos(m\\theta) \\end{pmatrix} $$\n旋转后的向量为：\n$$ \\mathbf{x}\u0026rsquo; = \\mathbf{R}_m \\mathbf{x} $$\n为什么这能代表相对位置？ 这是 RoPE 最精妙的地方。当我们计算 Query ($Q$) 在位置 $m$ 和 Key ($K$) 在位置 $n$ 的点积时：\n$$ \\text{Score}(m, n) = (\\mathbf{R}_m \\mathbf{q})^T (\\mathbf{R}_n \\mathbf{k}) = \\mathbf{q}^T \\mathbf{R}_m^T \\mathbf{R}n \\mathbf{k} = \\mathbf{q}^T \\mathbf{R}{n-m} \\mathbf{k} $$\n结论：点积的结果只与 $n-m$（即两个词的相对距离）有关。这意味着，尽管我们在每一层对 $Q$ 和 $K$ 做了绝对位置的旋转，但注意力机制捕捉到的却是相对距离。\n多维扩展与计算优化 对于一个 $d$ 维的高维向量，RoPE 会将其拆分成 $d/2$ 个二维对。每一对都会根据预设的频率 $\\theta_i$ 进行不同速度的旋转：\n$$ \\theta_i = 10000^{-2i/d} $$\n在实际代码实现中，我们不会真的去乘一个巨大的旋转矩阵（太慢了），而是利用复数乘法的简化形式：\n$$ \\begin{pmatrix} x_1 \\ x_2 \\end{pmatrix} \\to \\begin{pmatrix} x_1 \\cos(m\\theta) - x_2 \\sin(m\\theta) \\ x_1 \\sin(m\\theta) + x_2 \\cos(m\\theta) \\end{pmatrix} $$\n代码实现 // RoPE 相对位置编码：在每个头内旋转 q 和 k for (int i = 0; i \u0026lt; dim; i+=2) { int head_dim = i % head_size; float freq = 1.0f / powf(10000.0f, head_dim / (float)head_size); float val = pos * freq; float fcr = cosf(val); float fci = sinf(val); int rotn = i \u0026lt; kv_dim ? 2 : 1; // 2 = q \u0026amp; k, 1 = q only for (int v = 0; v \u0026lt; rotn; v++) { float* vec = v == 0 ? s-\u0026gt;q : s-\u0026gt;k; float v0 = vec[i]; float v1 = vec[i+1]; vec[i] = v0 * fcr - v1 * fci; vec[i+1] = v0 * fci + v1 * fcr; } } 为什么使用 head_dim 而不是全局索引 i？\n一个 dim 被切割成多个头的部分，每个头对应位置的频率相同。如果直接用全局索引 i 来计算频率，那么第一个头的旋转频率会非常快，而最后一个头的旋转频率会极其慢。这会导致不同的头对\u0026quot;位置\u0026quot;的理解完全不同，模型就乱套了。\n前向传播流程 完整的模型架构 输入端：词嵌入 + 位置编码 ↓ [Block 1] ↓ [Block 2] ↓ ... ↓ [Block N] ↓ 输出端：最终归一化 + 线性层 + Softmax 输入端：词嵌入与位置编码 在进入第一个 Block 之前，数据需要经过：\n词嵌入层（Token Embedding）：将 Token ID 转换成维度为 dim 的向量 位置编码（Positional Encoding / RoPE）：现代模型（如 Llama）通常在每一层的 Attention 内部直接应用旋转位置嵌入（RoPE），让模型知道词与词之间的相对距离 输出端：输出头 在最后一个 Block 输出向量后，需要将其变回人类可读的文字：\n最终归一化（Final LayerNorm / RMSNorm）：对最后一层的输出进行最后的数值校准 线性层（Linear/Language Model Head）：将维度从 dim 映射回巨大的词表维度（vocab_size）。得到一个长度为 vocab_size 的长向量 $z$，称为 Logits（原始得分）。向量中的每一个数代表了对应单词的\u0026quot;得分\u0026quot;，得分越高，模型认为该词出现的可能性越大 Softmax：计算出下一个 Token 出现的概率分布 优化组件 KV Cache（推理时存在）：在推理过程中，为了加速，模型会在内存中开辟一块空间，缓存每一层已经计算过的 Key 和 Value 向量 激活检测与 Dropout：在训练阶段存在，用于防止过拟合 前向传播代码流程 float* forward(Transformer* transformer, int token, int pos) { // 1. 获取 token 的嵌入向量 float* content_row = w-\u0026gt;token_embedding_table + token * dim; memcpy(x, content_row, dim*sizeof(*x)); // 2. 遍历所有层 for(unsigned long long l = 0; l \u0026lt; p-\u0026gt;n_layers; l++) { // 2.1 Attention 前的 RMSNorm rmsnorm(s-\u0026gt;xb, x, w-\u0026gt;rms_att_weight + l*dim, dim); // 2.2 计算 QKV matmul(s-\u0026gt;q, s-\u0026gt;xb, w-\u0026gt;wq + l*dim*dim, dim, dim); matmul(s-\u0026gt;k, s-\u0026gt;xb, w-\u0026gt;wk + l*dim*kv_dim, dim, kv_dim); matmul(s-\u0026gt;v, s-\u0026gt;xb, w-\u0026gt;wv + l*dim*kv_dim, dim, kv_dim); // 2.3 应用 RoPE // ... (旋转 Q 和 K) // 2.4 多头注意力 for (h = 0; h \u0026lt; p-\u0026gt;n_heads; h++) { // 计算注意力分数 for (int t = 0; t \u0026lt;= pos; t++) { score = dot_product(q, k[t]) / sqrt(head_size); att[t] = score; } // Softmax softmax(att, pos + 1); // 加权求和 for (int t = 0; t \u0026lt;= pos; t++) { xb += att[t] * v[t]; } } // 2.5 输出投影 matmul(s-\u0026gt;xb2, s-\u0026gt;xb, w-\u0026gt;wo + l*dim*dim, dim, dim); // 2.6 残差连接 for (int i = 0; i \u0026lt; dim; i++) { x[i] += s-\u0026gt;xb2[i]; } // 2.7 FFN 前的 RMSNorm rmsnorm(s-\u0026gt;xb, x, w-\u0026gt;rms_ffn_weight + l*dim, dim); // 2.8 FFN（SwiGLU） matmul(s-\u0026gt;hb, s-\u0026gt;xb, w-\u0026gt;w1 + l*dim*hidden_dim, dim, hidden_dim); matmul(s-\u0026gt;hb2, s-\u0026gt;xb, w-\u0026gt;w3 + l*dim*hidden_dim, dim, hidden_dim); // SwiGLU 激活 for (int i = 0; i \u0026lt; hidden_dim; i++) { s-\u0026gt;hb[i] = silu(s-\u0026gt;hb[i]) * s-\u0026gt;hb2[i]; } matmul(s-\u0026gt;xb, s-\u0026gt;hb, w-\u0026gt;w2 + l*dim*hidden_dim, hidden_dim, dim); // 2.9 残差连接 for (int i = 0; i \u0026lt; dim; i++) { x[i] += s-\u0026gt;xb[i]; } } // 3. 最终归一化 rmsnorm(x, x, w-\u0026gt;rms_final_weight, dim); // 4. 输出分类器 matmul(s-\u0026gt;logits, x, w-\u0026gt;wcls, p-\u0026gt;dim, p-\u0026gt;vocab_size); return s-\u0026gt;logits; } 分词器：BPE BPE（Byte Pair Encoding） BPE 是一种子词分词算法，它让模型能够自适应地处理文本：\n常见词（如 \u0026ldquo;the\u0026rdquo;）：直接合并成一个 Token，节省计算量 罕见词（如 \u0026ldquo;Transformer\u0026rdquo;）：可能拆成 \u0026ldquo;Trans\u0026rdquo;, \u0026ldquo;former\u0026rdquo; 两个 Token 从未见过的词：拆成一个个字节 编码流程 假设你输入 \u0026ldquo;hi\u0026rdquo;：\n准备：加上 \u0026lt;s\u0026gt; 和前置空格 \u0026quot; hi\u0026quot; 原子化：拆成 [空格, h, i] 寻找合并： 检查 (空格, h)，发现词典里有 \u0026quot; h\u0026quot;，分数 10.5 检查 (h, i)，发现词典里有 \u0026ldquo;hi\u0026rdquo;，分数 12.0 执行合并：因为 \u0026ldquo;hi\u0026rdquo; 分数更高，先合并后面，变成 [空格, hi] 再次合并：检查 (空格, hi)，如果词典里有 \u0026quot; hi\u0026quot; 且分数够高，合并成 [ hi] 输出：最后得到一个 ID 为什么要加前置空格？ 在英语中，绝大多数单词在句子中间出现时，前面都是带空格的（例如：\u0026ldquo;I like apples\u0026rdquo;）。因此，在模型训练时，词典里存储的 Token 大多是带前置空格的，比如：\nToken A: \u0026quot; apple\u0026quot; (ID: 12345) Token B: \u0026ldquo;apple\u0026rdquo; (ID: 6789) —— 这通常被视为一个完全不同的词 这是一个 Llama/SentencePiece 的特殊设计。它会在你的输入前面强行加一个空格。这样处理是为了让 \u0026ldquo;hello\u0026rdquo; 出现在句首和句中时，编码结果保持一致。\nUTF-8 处理 BPE 需要正确处理 UTF-8 编码的多字节字符。UTF-8 编码规则：\n码点范围 字节 1 字节 2 字节 3 字节 4 U+0000 - U+007F 0xxxxxxx U+0080 - U+07FF 110xxxxx 10xxxxxx U+0800 - U+FFFF 1110xxxx 10xxxxxx 10xxxxxx U+10000 - U+10FFFF 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx 关键判断：\n(*c \u0026amp; 0xC0) != 0x80：判断当前字节是不是 UTF-8 的起始字节 0xC0 是 11000000，0x80 是 10000000 在 UTF-8 中，所有后续字节都以 \u0026ldquo;10\u0026rdquo; 开头 编码代码流程 void encode(Tokenizer* t, char *text, int8_t bos, int8_t eos, int *tokens, int *n_tokens) { // 1. 添加 BOS token（如果需要） if (bos) tokens[(*n_tokens)++] = 1; // 2. 添加虚拟前置空格 if (text[0] != \u0026#39;\\0\u0026#39;) { int dummy_prefix = str_lookup(\u0026#34; \u0026#34;, t-\u0026gt;sorted_vocab, t-\u0026gt;vocab_size); tokens[(*n_tokens)++] = dummy_prefix; } // 3. UTF-8 初步拆解（原子化） for (char *c = text; *c != \u0026#39;\\0\u0026#39;; c++) { // 判断是否为起始字节 if ((*c \u0026amp; 0xC0) != 0x80) { str_len = 0; } str_buffer[str_len++] = *c; str_buffer[str_len] = \u0026#39;\\0\u0026#39;; // 继续读取后续字节 if ((*(c+1) \u0026amp; 0xC0) == 0x80 \u0026amp;\u0026amp; str_len \u0026lt; 4) { continue; } // 读完一个完整字符，查表 int id = str_lookup(str_buffer, t-\u0026gt;sorted_vocab, t-\u0026gt;vocab_size); if (id != -1) { tokens[(*n_tokens)++] = id; } else { // 字节回退：如果找不到，就按字节编码 for (int i=0; i \u0026lt; str_len; i++) { tokens[(*n_tokens)++] = (unsigned char)str_buffer[i] + 3; } } str_len = 0; } // 4. BPE 合并循环 while (1) { float best_score = -1e10; int best_id = -1; int best_idx = -1; // 扫描所有相邻 token 对 for (int i=0; i \u0026lt; (*n_tokens-1); i++) { sprintf(str_buffer, \u0026#34;%s%s\u0026#34;, t-\u0026gt;vocab[tokens[i]], t-\u0026gt;vocab[tokens[i+1]]); int id = str_lookup(str_buffer, t-\u0026gt;sorted_vocab, t-\u0026gt;vocab_size); if (id != -1 \u0026amp;\u0026amp; t-\u0026gt;vocab_scores[id] \u0026gt; best_score) { best_score = t-\u0026gt;vocab_scores[id]; best_id = id; best_idx = i; } } if (best_idx == -1) break; // 没有可合并的了 // 合并最佳对 tokens[best_idx] = best_id; for (int i = best_idx+1; i \u0026lt; (*n_tokens-1); i++) { tokens[i] = tokens[i+1]; } (*n_tokens)--; } // 5. 添加 EOS token（如果需要） if (eos) tokens[(*n_tokens)++] = 2; } 解码流程 解码相对简单，就是将 Token ID 映射回字符串：\nchar* decode(Tokenizer* t, int prev_token, int token) { char *piece = t-\u0026gt;vocab[token]; // BOS 后的空格处理 if (prev_token == 1 \u0026amp;\u0026amp; piece[0] == \u0026#39; \u0026#39;) { piece++; } // 处理字节 token（如 \u0026lt;0x01\u0026gt;） unsigned char byte_val; if (sscanf(piece, \u0026#34;\u0026lt;0x%02hhX\u0026gt;\u0026#34;, \u0026amp;byte_val) == 1) { piece = (char*)t-\u0026gt;byte_pieces + byte_val * 2; } return piece; } 完整流程示意 编码 (Encoding): \u0026#34;Hello\u0026#34; → [15496] ↓ 推理 (Inference): 模型处理 [15496] → 预测 [13]（对应 \u0026#34;,\u0026#34;） ↓ 解码 (Decoding): [13] → \u0026#34;,\u0026#34; ↓ 展示: safe_printf 打印 \u0026#34;,\u0026#34; 采样策略 如果说 forward（前向传播）是 Transformer 的\u0026quot;思考\u0026quot;过程，那么 **Sampler（采样器）**就是它的\u0026quot;决策过程\u0026quot;。\n在 forward 结束时，模型给出的不是一个确定的词，而是对词典中所有词（通常是 32,000 个）的\u0026quot;打分\u0026quot;（Logits）。采样器的任务就是根据这些分数和一些参数，最终拍板决定：下一个词到底选谁？\n三种采样风格 风格 参数条件 描述 效果 贪心采样 (Greedy) temp == 0 永远选概率最高的那一个 稳定、死板、容易循环 多项式采样 (Multinomial) temp \u0026gt; 0, topp == 1 按照概率分布\u0026quot;抽奖\u0026quot; 有创意，但也可能胡言乱语 核采样 (Top-P/Nucleus) 0 \u0026lt; topp \u0026lt; 1 只在概率加和达到 P 的\u0026quot;头部\u0026quot;词中抽奖 主流方案：平衡了多样性与逻辑性 1. 贪心采样（Greedy Sampling） 永远选择概率最高的那个词。\nint sample_argmax(float* probabilities, int n) { int max_i = 0; float max_p = probabilities[0]; for (int i = 1; i \u0026lt; n; i++) { if (probabilities[i] \u0026gt; max_p) { max_i = i; max_p = probabilities[i]; } } return max_i; } 优点：稳定、可复现 缺点：死板、容易陷入重复循环\n2. 多项式采样（Multinomial Sampling） 按照概率分布随机抽取。\nint sample_mult(float* probabilities, int n, float coin) { float cdf = 0.0f; for (int i = 0; i \u0026lt; n; i++) { cdf += probabilities[i]; if (coin \u0026lt; cdf) { return i; } } return n - 1; } 工作原理：\n从头依次累加概率 当累加值超过随机数 coin（0-1 之间）时，返回当前 token 优点：有创意、多样性高 缺点：可能选到低概率的\u0026quot;胡言乱语\u0026quot;\n3. 核采样（Top-P / Nucleus Sampling） 只在概率加和达到 P（如 0.9）的\u0026quot;核心\u0026quot;词中抽奖。\nint sample_topp(float* probabilities, int n, float topp, ProbIndex* probindex, float coin) { // 1. 预筛选：剔除极低概率的词 const float cutoff = (1.0f - topp) / (n - 1); int n0 = 0; for (int i = 0; i \u0026lt; n; i++) { if (probabilities[i] \u0026gt;= cutoff) { probindex[n0].index = i; probindex[n0].prob = probabilities[i]; n0++; } } // 2. 排序：按概率从高到低 qsort(probindex, n0, sizeof(ProbIndex), compare); // 3. 截断：累加到 topp 为止 float cumulative_prob = 0.0f; int last_idx = n0 - 1; for (int i = 0; i \u0026lt; n0; i++) { cumulative_prob += probindex[i].prob; if (cumulative_prob \u0026gt; topp) { last_idx = i; break; } } // 4. 在截断后的列表中采样 float r = coin * cumulative_prob; float cdf = 0.0f; for (int i = 0; i \u0026lt;= last_idx; i++) { cdf += probindex[i].prob; if (r \u0026lt; cdf) { return probindex[i].index; } } return probindex[last_idx].index; } 工作流程：\n预筛选：为了效率，先剔除掉概率极低的词（cutoff 逻辑） 排序：将候选词按概率从高到低排列 截断：从高到低累加概率，一旦总和达到 topp（例如 0.9），剩下的词全部扔掉 再采样：在剩下的这几个高概率词（\u0026ldquo;核\u0026rdquo;）中重新抽奖 优点：平衡了多样性与逻辑性，是目前主流方案\nTemperature（温度）参数 Temperature 控制概率分布的\u0026quot;陡峭程度\u0026quot;：\n// 应用温度 for (int q=0; q\u0026lt;sampler-\u0026gt;vocab_size; q++) { logits[q] /= sampler-\u0026gt;temperature; } // 再应用 softmax softmax(logits, sampler-\u0026gt;vocab_size); 效果：\ntemp = 0：贪心模式，永远选最高概率 temp = 1：保持原始分布 temp \u0026lt; 1（如 0.5）：分布更陡峭，更倾向于高概率词（更保守） temp \u0026gt; 1（如 1.5）：分布更平缓，低概率词也有机会（更随机） 完整采样流程 int sample(Sampler* sampler, float* logits) { int next; if (sampler-\u0026gt;temperature == 0.0f) { // 贪心采样 next = sample_argmax(logits, sampler-\u0026gt;vocab_size); } else { // 应用温度 for (int q=0; q\u0026lt;sampler-\u0026gt;vocab_size; q++) { logits[q] /= sampler-\u0026gt;temperature; } // Softmax softmax(logits, sampler-\u0026gt;vocab_size); // 生成随机数 float coin = random_f32(\u0026amp;sampler-\u0026gt;rng_state); // 选择采样方式 if (sampler-\u0026gt;topp \u0026lt;= 0 || sampler-\u0026gt;topp \u0026gt;= 1) { next = sample_mult(logits, sampler-\u0026gt;vocab_size, coin); } else { next = sample_topp(logits, sampler-\u0026gt;vocab_size, sampler-\u0026gt;topp, sampler-\u0026gt;probindex, coin); } } return next; } 推理循环 Generate 模式：文本续写 这是最基础的生成模式，给定一个 prompt，模型会一个词一个词地续写下去。\nvoid generate(Transformer *transformer, Tokenizer *tokenizer, Sampler *sampler, char *prompt, int steps) { // 1. 编码 prompt int num_prompt_tokens = 0; int* prompt_tokens = (int*)malloc((strlen(prompt)+3) * sizeof(int)); encode(tokenizer, prompt, 1, 0, prompt_tokens, \u0026amp;num_prompt_tokens); // 2. 初始化 int token = prompt_tokens[0]; int pos = 0; long start = 0; // 3. 主循环 while (pos \u0026lt; steps) { // 3.1 前向传播 float* logits = forward(transformer, token, pos); // 3.2 决定下一个 token int next; if (pos \u0026lt; num_prompt_tokens - 1) { // Prompt 填充期：强制使用下一个输入词 next = prompt_tokens[pos + 1]; } else { // 自主生成期：采样选词 next = sample(sampler, logits); } pos++; // 3.3 退出条件 if (next == 1) { break; } // 3.4 解码并显示 char* piece = decode(tokenizer, token, next); safe_printf(piece); fflush(stdout); // 流式输出 // 3.5 更新状态 token = next; // 3.6 启动计时器 if (start == 0) { start = time_in_ms(); } } // 4. 报告性能 if (pos \u0026gt; 1) { long end = time_in_ms(); fprintf(stderr, \u0026#34;achieved tok/s: %f\\n\u0026#34;, (pos-1) / (double)(end-start)*1000); } free(prompt_tokens); } 生成流程详解 每一步的循环包含：\n输入：上一次生成的 Token（或者 Prompt 的第一个词） 思考：Transformer 经过上千亿次浮点运算（forward） 决策：采样器在几万个候选词中挑一个（sample） 翻译：分词器把数字变回字符（decode） 反馈：把选中的词塞回模型，回到步骤 1 为什么要 fflush(stdout)？ 在 C 语言中，输出通常是\u0026quot;行缓冲\u0026quot;的。如果不加这一行，程序会等模型生成完一整行才一股脑显示出来。加上 fflush 后，你就能看到模型一个字、一个字蹦出来的效果，这种\u0026quot;流式输出\u0026quot;让 AI 看起来更像是在实时思考。\nChat 模式：对话引擎 Chat 模式在 Generate 的基础上增加了对话管理和特殊格式处理。\nLlama 2 Chat 格式 [INST] \u0026lt;\u0026lt;SYS\u0026gt;\u0026gt; {system_prompt} \u0026lt;\u0026lt;/SYS\u0026gt;\u0026gt; {user_prompt} [/INST] 组件说明：\n组件 对应代码/格式 形象比喻 核心功能 System Prompt \u0026lt;\u0026lt;SYS\u0026gt;\u0026gt; 内部内容 底层架构/剧本设定 设定 AI 的\u0026quot;人格\u0026quot;、\u0026ldquo;规则\u0026quot;和\u0026quot;知识边界\u0026rdquo; User Prompt [INST] 里的正文 甲方的具体要求 用户当前输入的问题或指令 Instruction [INST] 标签本身 工作指令信号灯 告诉模型：\u0026ldquo;别再瞎写了，现在开始干活！\u0026rdquo; 示例：\n[INST] \u0026lt;\u0026lt;SYS\u0026gt;\u0026gt; 你是一个严谨的法官，只回答法律问题。 \u0026lt;\u0026lt;/SYS\u0026gt;\u0026gt; 帮我写一份离婚协议书 [/INST] Chat 代码流程 void chat(Transformer *transformer, Tokenizer *tokenizer, Sampler *sampler, char *cli_user_prompt, char *cli_system_prompt, int steps) { char system_prompt[512]; char user_prompt[512]; char rendered_prompt[1152]; int* prompt_tokens = (int*)malloc(1152 * sizeof(int)); int8_t user_turn = 1; // 1=用户输入，0=模型回复 int pos = 0; while (pos \u0026lt; steps) { if (user_turn) { // 1. 获取 system prompt（仅第一次） if (pos == 0) { if (cli_system_prompt == NULL) { read_stdin(\u0026#34;Enter system prompt (optional): \u0026#34;, system_prompt, sizeof(system_prompt)); } else { strcpy(system_prompt, cli_system_prompt); } } // 2. 获取 user prompt if (pos == 0 \u0026amp;\u0026amp; cli_user_prompt != NULL) { strcpy(user_prompt, cli_user_prompt); } else { read_stdin(\u0026#34;User: \u0026#34;, user_prompt, sizeof(user_prompt)); } // 3. 拼接成 Llama 2 Chat 格式 if (pos == 0 \u0026amp;\u0026amp; system_prompt[0] != \u0026#39;\\0\u0026#39;) { sprintf(rendered_prompt, \u0026#34;[INST] \u0026lt;\u0026lt;SYS\u0026gt;\u0026gt;\\n%s\\n\u0026lt;\u0026lt;/SYS\u0026gt;\u0026gt;\\n\\n%s [/INST]\u0026#34;, system_prompt, user_prompt); } else { sprintf(rendered_prompt, \u0026#34;[INST] %s [/INST]\u0026#34;, user_prompt); } // 4. 编码 encode(tokenizer, rendered_prompt, 1, 0, prompt_tokens, \u0026amp;num_prompt_tokens); user_idx = 0; user_turn = 0; printf(\u0026#34;Assistant: \u0026#34;); } // 5. 确定输入 token if (user_idx \u0026lt; num_prompt_tokens) { token = prompt_tokens[user_idx++]; // 填充 KV Cache } else { token = next; } // 6. EOS (=2) 结束 Assistant 回合 if (token == 2) { user_turn = 1; } // 7. 前向传播 + 采样 float* logits = forward(transformer, token, pos); next = sample(sampler, logits); pos++; // 8. 显示 Assistant 的回复 if (user_idx \u0026gt;= num_prompt_tokens \u0026amp;\u0026amp; next != 2) { char* piece = decode(tokenizer, token, next); safe_printf(piece); fflush(stdout); } if (next == 2) { printf(\u0026#34;\\n\u0026#34;); } } free(prompt_tokens); } 两种模式的区别 特性 Generate 模式 Chat 模式 用途 文本续写 多轮对话 输入格式 纯文本 [INST]...[/INST] 格式 状态管理 单次生成 多轮交互 结束条件 达到 steps 或 BOS 达到 steps 或 EOS 典型应用 故事续写、代码补全 聊天机器人、问答系统 总结 Transformer 的核心思想 Self-Attention：让每个词都能\u0026quot;看到\u0026quot;句子中的其他词，理解上下文 Multi-Head：从多个角度理解同一个句子 FFN：对每个词进行深度加工，提取语义特征 残差连接：保留原始信息，防止梯度消失 层归一化：稳定训练过程，加速收敛 位置编码：让模型理解词的顺序关系 关键优化技术 1. KV Cache 问题：每生成一个新词，都要重新计算之前所有词的 K 和 V，非常浪费。\n解决：将每一层的 K 和 V 缓存起来，新词只需要计算自己的 K 和 V，然后与缓存的进行注意力计算。\n代价：显存占用大（可能达到几 GB）。\n2. Grouped-Query Attention (GQA) 问题：标准的 Multi-Head Attention 中，每个头都有自己的 K 和 V，显存占用巨大。\n解决：让多个 Q 头共享同一组 K 和 V 头。例如：\n8 个 Q 头 2 个 KV 头 每 4 个 Q 头共享 1 组 KV 效果：显存占用减少 75%，性能损失很小。\n3. RoPE（旋转位置编码） 优势：\n相对位置编码，泛化能力强 可以外推到更长的序列 计算高效，不需要额外的参数 4. RMSNorm 相比 LayerNorm 的优势：\n不需要计算均值，只计算均方根 计算量减少约 50% 效果几乎一样 5. SwiGLU 激活函数 相比 ReLU 的优势：\n更平滑的梯度 门控机制增强表达能力 现代大模型的标准配置 内存管理策略 Weights（权重）：使用 mmap *data = mmap(NULL, *file_size, PROT_READ, MAP_PRIVATE, *fd, 0); 优势：\n零拷贝：权重数据不需要从内核缓冲区拷贝到用户缓冲区 延迟加载：操作系统仅在实际访问某页权重时才将其载入物理内存 节省内存：多个进程可以共享同一份权重文件 RunState（激活值）：使用 malloc s-\u0026gt;x = calloc(p-\u0026gt;dim, sizeof(float)); 原因：\n激活值需要频繁读写 每个进程都有自己的激活值 使用堆内存更灵活 模型文件结构 [Config 结构体] [Token Embedding Table] [Layer 0 RMS Attention Weight] [Layer 1 RMS Attention Weight] ... [Layer 0 WQ] [Layer 1 WQ] ... [Layer 0 WK] [Layer 1 WK] ... [Layer 0 WV] [Layer 1 WV] ... [Layer 0 WO] [Layer 1 WO] ... [Layer 0 RMS FFN Weight] [Layer 1 RMS FFN Weight] ... [Layer 0 W1] [Layer 1 W1] ... [Layer 0 W2] [Layer 1 W2] ... [Layer 0 W3] [Layer 1 W3] ... [Final RMS Weight] [RoPE Freq (跳过)] [Classifier Weights (可选)] 理论到代码的映射 理论概念 代码实现 数据结构 Token Embedding token_embedding_table float[vocab_size][dim] Query/Key/Value wq, wk, wv float[n_layers][dim][dim] Multi-Head Attention for (h = 0; h \u0026lt; n_heads; h++) 循环并行 Attention Score score = dot(q, k) / sqrt(head_size) float[n_heads][seq_len] Softmax softmax(att, pos + 1) 原地修改 Weighted Sum xb += att[t] * v[t] 累加 FFN w1, w2, w3 + SwiGLU float[n_layers][hidden_dim][dim] Residual x[i] += xb[i] 逐元素相加 LayerNorm rmsnorm(xb, x, weight, dim) 归一化 + 缩放 RoPE 旋转矩阵应用 原地修改 Q 和 K KV Cache key_cache, value_cache float[n_layers][seq_len][kv_dim] Logits matmul(logits, x, wcls, dim, vocab_size) float[vocab_size] Sampling sample(sampler, logits) 返回 token ID ","permalink":"https://caulif.com/posts/transfromer%E5%AD%A6%E4%B9%A0%E8%AE%B0%E5%BD%95/","summary":"基于 llama2.c 的 run.c 源码注释，梳理 Decoder-only Transformer 的推理流程、RoPE、Attention、FFN、采样和 BPE。","title":"Transformer 学习记录"},{"content":"很久之前就想弄博客，今天先把这里重新整理起来。\n之后会继续记录技术学习、AI 工具折腾和一些生活碎片。写得不一定很快，但希望每一篇都能留下当时真实的问题、理解和变化。\n","permalink":"https://caulif.com/posts/first-post/","summary":"很久之前就想弄博客，现在把这里重新整理起来，继续记录技术和生活。","title":"博客重新开始"}]