大模型打分与采样原理,以及 Pi Agent 核心原理

34 阅读1分钟

大模型打分与采样原理,以及 Pi Agent 核心原理

本文分三部分:

  1. 大模型如何根据用户提示词给"自己认识的词"打分(logits 与 softmax)
  2. temperature 与 top-p 到底改变了什么
  3. Pi Agent(pi.dev,Mario Zechner / Earendil Inc.)的核心架构原理

第一部分:大模型如何给词打分

1.1 一句话结论

大模型本质上是一个"给词表里每一个候选词打分"的函数。给定已有的上下文(系统提示词 + 用户提示词 + 已生成的部分),模型输出一个长度等于词表大小的分数向量,再把这些分数转成概率分布,最后从分布里"抽"一个词出来,追加到上下文尾部,重复这个过程。

用公式表达就是:

P(xtx1,x2,,xt1)=softmax(zt),ztRVP(x_t \mid x_1, x_2, \dots, x_{t-1}) = \mathrm{softmax}(z_t), \quad z_t \in \mathbb{R}^{|V|}

其中 ztz_t 就是 logits(未归一化的原始分数),V|V| 是词表大小(常见规模 3 万到 20 万)。

1.2 "自己认识的词"是什么:词表与 Tokenizer

模型并不认识"字"或"单词",它只认识 token(子词单元)。Tokenizer(通常是 BPE 或 SentencePiece)在训练前就固定下来了一份词表,比如:

id 15496 -> "Hello"
id 11     -> ","
id 1917  -> " world"
id 30    -> "?"

中文一般 1 个汉字对应 1 到 2 个 token,英文一个常见单词往往是 1 个 token。词表是封闭集合:模型永远只能在这份固定的 token 列表里打分,这就是"模型认识的所有词"。词表之外的内容会被拆成更小的碎片(极端情况拆到字节级),所以模型不会"不认识",只会"拆得更碎"。

1.3 从提示词到分数:完整前向流程

graph TD
    A[用户提示词,纯文本] --> B[Tokenizer 切分为 token id 序列]
    B --> C[Embedding 查表,每个 id 变成 d 维向量]
    C --> D[加入位置信息,RoPE 或位置编码]
    D --> E[Transformer Block 1,自注意力加 FFN]
    E --> F[Transformer Block 2 直到 N]
    F --> G[取最后一个位置的隐状态 h]
    G --> H[LM Head 线性投影,z 等于 W 乘 h]
    H --> I[logits 向量,长度等于词表大小]
    I --> J[按 temperature 缩放]
    J --> K[softmax 得到概率分布]
    K --> L[截断策略,top-k 或 top-p 或 min-p]
    L --> M[随机采样,得到下一个 token]
    M --> N[追加到序列尾部]
    N --> O{是否遇到停止条件}
    O -->|否| C
    O -->|是| P[输出完整回答]

1.4 关键步骤解析

Embedding(认识词的向量表示)

词表里每个 token 对应一个可训练的 dd 维向量(dd 常见 2048 到 8192)。语义相近的 token,其向量在空间中距离更近。这一步把离散符号变成连续数值。

自注意力(理解提示词内部的关系)

每个 token 生成 Query、Key、Value 三个向量,然后计算:

Attention(Q,K,V)=softmax ⁣(QKdk+M)V\mathrm{Attention}(Q, K, V) = \mathrm{softmax}\!\left(\frac{QK^{\top}}{\sqrt{d_k}} + M\right)V

QKQK^\top 是 token 之间的相关性打分矩阵,MM 是因果掩码(保证第 tt 个位置看不到未来的内容)。这一步的作用是:让"最后一个位置"的隐状态汇聚整段提示词中与预测下一词最相关的信息。提示词写得越明确,注意力权重就越集中在有效约束上,输出分布也就越尖锐。

LM Head(真正的打分环节)

最后一层隐状态 hRdh \in \mathbb{R}^{d} 与输出矩阵 WRV×dW \in \mathbb{R}^{|V| \times d} 相乘:

zi=Wihz_i = W_i \cdot h

ziz_i 就是"第 ii 个 token 的得分"。很多模型的 WW 与输入 embedding 权重共享(weight tying),所以这一步可以直观理解为:把当前语义向量与词表里每个词的向量做点积,谁最像谁得分高

1.5 一个可手算的例子

假设词表只有 5 个 token,提示词是"今天天气真",模型给出的 logits 为:

"好"   -> 6.0
"热"   -> 5.2
"冷"   -> 4.5
"香"   -> 0.3
"编译" -> -2.1

softmax(T=1T = 1)后的概率约为:

"好"   -> 0.55
"热"   -> 0.25
"冷"   -> 0.12
"香"   -> 0.004
"编译" -> 0.0002

注意几点:

  • logits 是相对量,整体加减一个常数不改变 softmax 结果
  • 分数差 1.0 大约意味着概率相差 e2.7e \approx 2.7
  • 语法上不可能的词("编译")会得到极低分,这是模型在预训练中学到的统计规律,不是规则引擎判断

1.6 logits 在 softmax 前还可能被改写

工程实现里,logits 出来后往往不是直接 softmax,而是先经过一系列 logits processors

graph LR
    A[原始 logits] --> B[重复惩罚与频率惩罚]
    B --> C[logit_bias 人工偏置]
    C --> D[结构约束遮罩,JSON Schema 或语法约束]
    D --> E[temperature 缩放]
    E --> F[softmax]
    F --> G[top-k 截断]
    G --> H[top-p 截断]
    H --> I[重新归一化]
    I --> J[按概率抽样]

其中"结构约束遮罩"是 Function Calling 与 JSON 模式能稳定输出的关键:把所有不符合语法的 token 的 logit 直接置为 -\infty,softmax 后概率变成 0,模型物理上不可能选到它。


第二部分:temperature 与 top-p

2.1 temperature:改变分布的"陡峭程度"

带温度的 softmax:

pi=exp(zi/T)j=1Vexp(zj/T)p_i = \frac{\exp(z_i / T)}{\sum_{j=1}^{|V|} \exp(z_j / T)}

TT 只做一件事:在 softmax 之前把所有 logits 同时除以 TT

  • T0+T \to 0^{+}:分布退化为 one hot,等价于 greedy decoding,永远选最高分的词,输出完全确定
  • T=1T = 1:使用模型训练时的原始分布
  • T>1T > 1:logits 被压缩,高低分差距缩小,分布变平,低概率词有更大机会被选中
  • TT \to \infty:趋近于在整个词表上均匀随机,输出变成乱码

沿用上面的例子:

token    T=0.2      T=1.0     T=2.0
"好"     0.982      0.55      0.36
"热"     0.018      0.25      0.24
"冷"     0.0006     0.12      0.17
"香"     ~0         0.004     0.07
"编译"   ~0         0.0002    0.03

关键认知:temperature 不会让模型"更聪明"或"更有创意",它只是改变了从同一份打分结果里抽样的随机性强度。 分数排序永远不变,变的只是低分词被抽中的概率。

graph TD
    A[同一份 logits] --> B[除以 T]
    B --> C{T 的取值}
    C -->|T 接近 0| D[分布极尖锐,确定,重复,可能陷入循环]
    C -->|T 约等于 1| E[分布自然,兼顾正确性与多样性]
    C -->|T 大于 1.5| F[分布平坦,发散,易出现事实错误与语法崩坏]

2.2 top-p(核采样 Nucleus Sampling):动态截断候选集

top-p 的做法是:

  1. 把所有 token 按概率降序排列
  2. 从高到低累加概率
  3. 累加和首次达到或超过 pp 时停止,前面这些 token 构成"核"(nucleus)
  4. 丢弃核之外的所有 token,对核内概率重新归一化
  5. 在核内采样
V(p)=argminS{S:iSpip}V^{(p)} = \arg\min_{S} \left\{ |S| : \sum_{i \in S} p_i \geq p \right\}
graph TD
    A[softmax 概率分布] --> B[按概率降序排序]
    B --> C[累加概率 cum 加上 p_i]
    C --> D{cum 是否达到 p}
    D -->|否| E[把该 token 纳入候选核]
    E --> C
    D -->|是| F[纳入该 token 后停止]
    F --> G[丢弃核外全部 token]
    G --> H[核内概率重新归一化]
    H --> I[在核内随机抽样]

top-p 与 top-k 的区别:top-k 固定候选数量(比如永远取前 50),top-p 让候选数量随分布形状自适应

  • 模型很确定时(比如代码里 func 后面接函数名,或者 """ 闭合),最高概率可能就有 0.95,此时 p=0.9p = 0.9 的核里只有 1 个 token,等价于贪心,避免了 top-k 强行引入 49 个垃圾候选
  • 模型不确定时(比如写一段散文的开头),概率分散,核里可能有几百个 token,保留了多样性

这就是 top-p 通常优于 top-k 的原因:它对"模型自信程度"是自适应的。

2.3 两者的协同关系

temperature 与 top-p 作用在流水线的不同环节,不是二选一:

graph LR
    A[logits] --> B[temperature 重塑分布形状]
    B --> C[softmax]
    C --> D[top-p 切掉长尾]
    D --> E[采样]

顺序很重要:temperature 先改形状,top-p 再按改完的形状截断。所以高 temperature 加低 top-p,是"先把分布拉平,再只保留头部",两者会部分抵消;低 temperature 加高 top-p,则截断几乎不起作用。

长尾为什么必须切掉:词表里有十万个 token,即使每个只有 10510^{-5} 的概率,加起来也可能占到 20% 到 30% 的概率质量。生成 500 个 token 的回答,就有相当高的累计概率至少踩中一次完全离谱的词,而 Transformer 的自回归特性会让这一个错词污染后面全部内容(俗称"一步错,步步错")。top-p 就是防止这种尾部灾难的闸门。

2.4 实践取值建议

场景temperaturetop-p说明
代码生成、工具调用、结构化输出0 到 0.20.9 或 1.0追求确定性与可复现
事实问答、文档摘要、翻译0.2 到 0.50.9少量灵活性,避免机械重复
通用对话0.7 到 0.80.9 到 0.95主流默认值
创意写作、头脑风暴0.9 到 1.20.95允许发散
数据增强、多样化候选1.0 以上0.98需要配合后置过滤

补充要点:

  • 一般只调其中一个。同时调两个会让效果难以归因,OpenAI 官方文档也建议二者择一
  • T=0T = 0 并不严格等于"完全可复现"。GPU 浮点归约顺序、批处理组合、MoE 路由都会引入抖动,同一个请求仍可能有细微差异
  • 温度过低容易触发重复退化(同一句话反复输出),因为一旦进入某个高概率环路就再也跳不出来,此时需要用 repetition penalty 或 presence penalty 而不是靠升温解决
  • 推理型模型(带 thinking 的模型)通常要求固定采样参数,官方推荐值不建议改动,否则会破坏思维链质量

第三部分:Pi Agent 核心原理

Pi(@earendil-works/pi,官网 pi.dev)是 libGDX 作者 Mario Zechner 主导开发的开源终端编码 Agent,MIT 协议,TypeScript 实现。它的设计哲学与 Claude Code、Codex 这类"功能完备"路线相反,核心口号是 Primitives, not features(要原语,不要功能),以及 最小核心,最大扩展

3.1 四层包结构

graph TD
    A[pi-coding-agent,CLI 主应用,组装一切] --> B[pi-agent-core,Agent 运行时与事件流]
    A --> C[pi-tui,差异化渲染的终端 UI 库]
    B --> D[pi-ai,统一多家 LLM 的抽象层]
    D --> E[OpenAI,Anthropic,Google,xAI,Ollama,vLLM]
  • pi-ai:抹平各家 API 的"方言"差异,提供 stream() 流式与 complete() 同步两个高阶函数,统一处理 token 计费、成本估算、prompt caching、中止信号、结构化工具结果
  • pi-agent-core:纯逻辑层,不依赖 UI,唯一职责是持有 Agent 状态并驱动 Agent Loop,可被 CLI、SDK、RPC 服务端任意消费
  • pi-tui:无闪烁、保留滚动缓冲区的差异化渲染 TUI 框架
  • pi-coding-agent:把上面三者串起来,提供 Interactive(TUI)、Print(stdout)、RPC(JSONL over stdin/stdout)三种运行模式

3.2 核心抽象只有三个类型

Pi 把整个 Agent 运行时解耦成"状态 · 行为注入 · 事件协议"三层,对应三个类型:

  • AgentMessage:内部统一的消息表示,直接使用某家厂商的格式
  • AgentLoopConfig:行为注入点集合(convertToLlmtransformContexttoolExecutionbeforeToolCallafterToolCallabortController
  • AgentEvent:对外发射的事件协议

Agent 类本身是一个状态机,对外只有四个公共方法:

  • prompt():发起新一轮交互
  • continue():从上次中断或报错处恢复
  • abort():紧急中止
  • waitForIdle():等待空闲

状态通过 agent.state(只读快照)暴露,messages 数组是会话持久化与恢复的唯一数据源

3.3 Agent Loop:Agent"能做事"的本质

Agent Loop 的本质就一句话:调用模型,判断是否要用工具,用完把结果塞回上下文,再调模型,直到模型不再请求工具

graph TD
    A[用户输入触发 prompt 方法] --> B[发出 loop_start 事件]
    B --> C[检查 Steering 高优队列与 Follow-up 低优队列]
    C --> D[transformContext 上下文修剪与压缩]
    D --> E[convertToLlm 最晚时刻转换为目标厂商格式]
    E --> F[调用 LLM,流式发射 assistant_message_delta]
    F --> G{响应里是否包含 tool_call}
    G -->|否| H[发出 loop_end,回到 idle 状态]
    G -->|是| I[beforeToolCall 钩子]
    I --> J{工具编排策略}
    J -->|并行 Promise.all| K[执行独立工具]
    J -->|顺序 for of| L[执行有副作用的工具]
    K --> M[afterToolCall 钩子]
    L --> M
    M --> N[把 tool_result 作为消息注入 messages]
    N --> C
    F --> O{是否发生错误}
    O -->|429 或 503 或超时| P[指数退避重试]
    P --> E
    O -->|401 或 400 或 402| Q[不可恢复,终止并保留错误消息]
    Q --> R[用户调用 continue 方法,让模型看到错误后自行调整]
    R --> C

几个值得单独拿出来讲的设计:

没有 maxSteps。作者的原话是"我从来没找到需要 maxSteps 的用例,所以为什么要加"。循环靠"模型不再请求工具"自然结束。

最晚转换(Late Conversion)。内部始终用 AgentMessage,只在真正要发 HTTP 请求的那一刻才通过 convertToLlm 转成 OpenAI 或 Anthropic 格式。好处是同一段会话历史可以在中途换模型、换厂商继续跑,即所谓 Context Handoff。

错误即状态。报错不清空上下文,错误消息本身留在 messages 里,continue() 后模型能看到"我上次调用失败了,原因是 X",从而自己换策略。

3.4 事件系统:可观测性的工程体现

pi-agent-core 基于 Observer 模式暴露 12 种以上事件,所有事件顺序发出并同步等待监听器,这是 TUI 渲染和扩展生命周期的底层支撑:

graph LR
    A[Agent 状态机] --> B[status_change]
    A --> C[user_message]
    A --> D[assistant_message_delta]
    A --> E[tool_call]
    A --> F[tool_result]
    A --> G[llm_request_start]
    A --> H[llm_request_end]
    A --> I[error]
    A --> J[loop_start]
    A --> K[loop_end]
    B --> L[pi-tui 渲染]
    D --> L
    E --> L
    F --> L
    I --> M[Extension 监听与副作用]
    J --> M
    K --> M

设计原则是"拒绝黑盒 Agent":每一次工具执行、每一次 LLM 请求都必须对用户可见。

3.5 Steering 与 Follow-up:运行中动态介入

Pi 用两条优先级不同的队列实现"Agent 跑着的时候也能插话":

graph TD
    A[用户在 Agent 运行中输入] --> B{输入类型}
    B -->|纠偏指令| C[进入 Steering 高优先级队列]
    B -->|补充任务| D[进入 Follow-up 低优先级队列]
    C --> E[下一次 loop 迭代开头立即取出并注入上下文]
    D --> F[等本轮任务完全结束后才取出执行]
    E --> G[Agent 立刻按新指令调整方向]
    F --> H[Agent 顺序处理下一个任务]

配合 abort() 取消,就构成了"完全可观测 + 可介入"的交互模型:用户不需要等 Agent 撞完南墙再重来。

3.6 上下文工程:Pi 的核心洞见

Pi 认为 Coding Agent 的胜负手是上下文工程,即精确控制进入模型的每一个 token。两个可覆盖函数承担全部职责:

  • transformContext(messages):每次 LLM 调用之前执行,负责修剪、压缩、丢弃过期的工具输出
  • convertToLlm(messages):负责格式转换,且自定义消息类型不会泄漏给模型

对应"三大原罪"的批判:现有框架在背后偷偷注入大量 UI 上看不到的 prompt,导致开发者无法归因输出质量问题。Pi 的系统提示词刻意压到 1000 token 以内

3.7 "刻意不做"的清单

这是 Pi 最有辨识度的部分,也是理解其原理的关键:

刻意不做理由与替代方案
不做 Plan ModePLAN.md 文件替代:可版本控制、可跨会话共享、完全可见
不做内置 Todo任务列表放 TODO.md,文件是最好的持久化
不支持 MCPMCP 工具描述会占掉 7% 到 9% 的上下文窗口(Playwright MCP 一上来就 13k token)。替代方案是 CLI 工具加 README,Agent 要用什么就先读那个 README 再用 bash 调用,这才是自然的渐进式披露
不做 Sub Agent子 Agent 是"黑盒中的黑盒",会丢失可观测性。需要并发时通过 bash 自我调用 pi,输出仍然完整可见
不做 maxSteps循环自然结束
不做权限检查"安全措施大多是安全剧场。一旦 Agent 能写代码并运行代码,游戏就结束了。"Agent 完全可以写个 Python 脚本绕过任何文件系统沙箱。Pi 选择 YOLO by default,把隔离责任交给容器或虚拟机
不做后台 bash 管理改用 tmux,长时进程的可观测性交给成熟工具

3.8 工具集:4 个原语撑起 90% 场景

Pi 的默认工具集极小,核心是 read / write / edit / bash 四个,编码版本额外提供 grep / find / ls。全部通过 ExecutionEnv 抽象层执行,与 Node.js 解耦,因此可以换成远程执行、容器执行。

为什么 4 个工具够了bash 是万能逃生舱。任何专用工具(git 操作、包管理、HTTP 请求、数据库查询)都可以表达成一条 shell 命令,而 shell 命令是 LLM 在预训练语料里见过最多的"API",成功率天然高于任何自定义 JSON schema。相比之下,每加一个专用工具,就要往上下文里塞一份 schema,挤占的是真正有用的代码空间。

3.9 扩展机制:Extension、Skills、Pi Packages

graph TD
    A[Pi 极简核心] --> B[Extension,TypeScript 模块,默认导出接收 ExtensionAPI 的函数]
    A --> C[Skills,放在用户目录 pi agent skills 下的技能包,按需加载]
    A --> D[提示词模板与主题]
    A --> E[Pi Packages,可分发的能力集合]
    B --> F[注册工具,监听事件,改写上下文]
    C --> G[渐进式披露,先读 SKILL.md 描述,命中才加载正文]
    E --> H[团队内共享工作流]

Extension 能拿到完整的 ExtensionAPI,可以注册新工具、订阅事件、注入或改写上下文,甚至替换 transformContext。Skills 则是"文档即能力"的思路:不常驻上下文,只有描述命中当前任务时才把正文读进来。

3.10 Proxy Stream:带宽优化

面向 Web 场景(浏览器到代理服务器再到 LLM),Pi 的 proxy.ts 做了一个针对性优化:服务端传输 partial 字段(完整的局部消息对象),只传 contentIndexdelta 的轻量事件,客户端通过 processProxyEvent() 逐步重建完整消息。这样流式输出的带宽从"每个 token 一份完整快照"降到"每个 token 一个增量"。

3.11 三种部署拓扑

传输层抽象把 LLM 调用实现与 Agent 逻辑解耦:

graph LR
    A[进程内直接模式,CLI 直接调 LLM] --> D[同一套 Agent 逻辑]
    B[跨进程 RPC 模式,JSONL 走 stdin 与 stdout] --> D
    C[跨网络 Proxy 模式,浏览器经代理服务器] --> D

3.12 测试:Harness

Pi 提供 Harness 测试工具,用 Mock 模型加可控工具,在不启动 TUI 的情况下对 Agent 做确定性单元测试与集成测试。这里就直接呼应了第二部分:测试 Agent 必须把 temperature 设为 0 或直接用 Mock 模型,否则采样随机性会让断言不稳定。


三部分之间的关联

graph TD
    A[用户提示词与工具 schema 进入上下文] --> B[模型对词表打分产生 logits]
    B --> C[temperature 与 top-p 决定采样确定性]
    C --> D[采样出的 token 序列构成回答或 tool_call]
    D --> E[Agent Loop 解析 tool_call 并执行工具]
    E --> F[工具结果作为新消息注入上下文]
    F --> A

三个要点串起来:

  1. 上下文质量决定打分质量。Pi 把系统提示词压到 1000 token、拒绝 MCP 的 13k token 工具描述,本质上是在保护注意力预算,让模型的注意力集中在真正相关的代码上,从而得到更尖锐、更可靠的概率分布。
  2. Agent 场景必须压低随机性。工具调用的参数一个字符错了就整体失败,所以 tool call 场景通常 temperature 取 0 到 0.2,并配合结构化约束遮罩把非法 token 的 logit 置为 -\infty
  3. 循环放大误差。单次采样 1% 的出错概率,在 20 轮工具调用的循环里会累积成显著失败率。这就是 Pi 强调可观测性与 Steering 介入的工程动机:既然无法消灭概率误差,就让人能随时看见并纠偏

参考

  • Pi 官方仓库:earendil-works/pi,官网 pi.dev
  • Holtzman et al., The Curious Case of Neural Text Degeneration(top-p 核采样原始论文)
  • Vaswani et al., Attention Is All You Need