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

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

本文分三部分:

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

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

1.1 一句话结论

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

用公式表达就是:
P(xt∣x1,x2,..., xt−1 )=softmax(zt),zt∈R∣V∣ P(x_t \mid x_1, x_2, \dots, x_{t-1}) = \mathrm{softmax}(z_t), \quad z_t \in \mathbb{R}^{|V|} P(xt∣x1,x2,...,xt−1)=softmax(zt),zt∈R∣V∣

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

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

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

text 复制代码
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 d 维向量( dd d 常见 2048 到 8192)。语义相近的 token,其向量在空间中距离更近。这一步把离散符号变成连续数值。

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

每个 token 生成 Query、Key、Value 三个向量,然后计算:
Attention(Q,K,V)=softmax ⁣ ( QK⊤ dk +M) V \mathrm{Attention}(Q, K, V) = \mathrm{softmax}\!\left(\frac{QK^{\top}}{\sqrt{d_k}} + M\right)V Attention(Q,K,V)=softmax(dk QK⊤+M)V

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

LM Head(真正的打分环节)

最后一层隐状态 h∈Rdh \in \mathbb{R}^{d} h∈Rd 与输出矩阵 W∈R∣V∣×dW \in \mathbb{R}^{|V| \times d} W∈R∣V∣×d 相乘:
zi=Wi⋅h z_i = W_i \cdot h zi=Wi⋅h

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

1.5 一个可手算的例子

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

text 复制代码
"好"   -> 6.0
"热"   -> 5.2
"冷"   -> 4.5
"香"   -> 0.3
"编译" -> -2.1

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

text 复制代码
"好"   -> 0.55
"热"   -> 0.25
"冷"   -> 0.12
"香"   -> 0.004
"编译" -> 0.0002

注意几点:

  • logits 是相对量,整体加减一个常数不改变 softmax 结果
  • 分数差 1.0 大约意味着概率相差 e≈2.7e \approx 2.7 e≈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=1∣V∣ exp⁡(zj/T) p_i = \frac{\exp(z_i / T)}{\sum_{j=1}^{|V|} \exp(z_j / T)} pi=∑j=1∣V∣exp(zj/T)exp(zi/T)

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

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

沿用上面的例子:

text 复制代码
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 p 时停止,前面这些 token 构成"核"(nucleus)
  4. 丢弃核之外的所有 token,对核内概率重新归一化
  5. 在核内采样

V(p)=arg⁡ min⁡S {∣S∣: ∑i∈S pi≥p} V^{(p)} = \arg\min_{S} \left\{ |S| : \sum_{i \in S} p_i \geq p \right\} V(p)=argSmin{∣S∣:i∈S∑pi≥p}

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 p=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,即使每个只有 10−510^{-5} 10−5 的概率,加起来也可能占到 20% 到 30% 的概率质量。生成 500 个 token 的回答,就有相当高的累计概率至少踩中一次完全离谱的词,而 Transformer 的自回归特性会让这一个错词污染后面全部内容(俗称"一步错,步步错")。top-p 就是防止这种尾部灾难的闸门。

2.4 实践取值建议

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

补充要点:

  • 一般只调其中一个。同时调两个会让效果难以归因,OpenAI 官方文档也建议二者择一
  • T=0T = 0 T=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 Mode PLAN.md 文件替代:可版本控制、可跨会话共享、完全可见
不做内置 Todo 任务列表放 TODO.md,文件是最好的持久化
不支持 MCP MCP 工具描述会占掉 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
相关推荐
一位正在转型AI全栈的前端工程师35 分钟前
AI 全栈学习之旅 -Week 5:RAG 知识库问答系统:从零到生产级部署的全栈实践总结
前端·python
光影少年1 小时前
react navite 安卓iOS 打包、签名、环境区分
前端·react native·react.js
coderCN1 小时前
Nodejs 第三十四章 数据库(表达式和函数、子查询和连表)
前端·node.js
ssshooter2 小时前
AI 时代你不能不知道的 git worktree
前端·后端·面试
观测云2 小时前
AI时代的用户访问监测:观测云带你身临其境体验用户与前端UI交互旅程
前端·可观测性·观测云·rum
喵本喵叁肆2 小时前
06-M6-部门过滤与综合研判-从问答机到研判助手
前端·javascript·jquery
TomEval3 小时前
【Web UI 自动化】05 - KDT 模式原理与实现
前端·ui·自动化
南雨北斗3 小时前
vue3项目状态持久化方案Pinia
前端