31 · Agent 循环从 6201 ms 压到 1677 ms:共用前向 + 增量分类
本篇对应源码:
main/kmcu.c·tools/patch_cls_head.py·docs/optimization_plan.zh-CN.md
目标:Agent 每步「先判断、再生成」,不要每步把同一段上下文重算一遍。
结论:transformer 前向次数 120 → 60 → 11,8 步总耗时 6201 → 1755 ms(−71.7%),输出 SHA-256 不变。
关键词:Agent 循环 · 增量分类 · KV 复用 · 位级对拍
封面:8 步 6201 → 1677 ms,输出一字不差
一、结论先行
| 构建 | transformer 前向次数(8 步) | 8 步总耗时 |
|---|---|---|
| 旧路径(prefill / 逐 token 全 forward) | 120 | 未单独实测 |
| O-2a(每步完整分类) | 60 | 6201 ms |
| O-2b(增量分类) | 11 | 1739 / 1755 ms |
| O-2b + 双核输出头(E-6) | 11 | 1677 ms |
前向次数是按执行量推导的值;耗时是板端实测。两者不混用。
二、Agent 循环在做什么
step: cls(上下文) → action ├─ generate: head(最后一个 hidden) → token → 追加 ├─ route : 换工具,继续 └─ stop : 结束朴素实现每一步都把「上下文 + 新 token」整段重跑一遍。8 步下来,同一段
前缀被算了 8 次——这是 O-2 要解决的唯一问题。
三、O-2a:一次前向,两个出口
分类需要「整段上下文的 pooling」,生成需要「最后一个 token 的 hidden」。
两者其实是同一次 forward 的两个出口:
/* 分类,同时把末 token 的 final hidden 带出来 */intkm_cls_forward_ex(...,float*last_hidden,float*pool_sum,...);/* 从 hidden 直接算输出头,不重跑 transformer */intkm_decode_head(...,constfloat*h,...);Agent 的 generate 分支不再重置上下文、不再重算,直接用last_hidden进 head。
关键证据(缺一不可):
cls last hidden == decode final hidden(FNV 哈希相等);- 从 hidden 算出的 token、best logit、top-8、完整 logits 哈希,与完整 decode 全等;
- 强制选 generate 的微型模型上,三种构建(默认 /
REUSE_KV=0/REUSE_KV=0+AGENT_PREFILL=0)
的完整输出 SHA-256 完全相同。
有了tools/patch_cls_head.py之后,「强制走 generate 分支」变成可复现的实验条件,
否则 Agent 很容易第一步就 stop,什么都测不到。
四、O-2b:增量分类
分类的输入是 pooling(对 hidden 求平均)。既然 KV 已经缓存、pos已经推进,
每一步只需要处理新增的那一个 token:
intkm_decode_hidden(...);/* 单 token transformer + final_norm,写 KV,推进 pos */intkm_cls_head_from_sum(...);/* 从未除长度的 pooling 累加和算 cls_head */两个细节决定了它必须是位级等价的:
- pooling 保存的是未除长度的累加和,最后一步才除——否则每步除一次会引入不同的舍入;
- 累加顺序严格按原 token 顺序,不允许「新 token 先加」。
首次调用仍走完整分类(需要建立初始 pooling),之后每步只做 1 次 forward。
8 步 + 初始 4 token:4 + 1 + 6 = 11次。
五、验证
四种构建(增量 / 非增量 / 不复用 KV / 旧逐 token)在强制 generate 的 8 步里
输出 SHA-256 完全相同;非增量路径的 dump 仍是 216 条MATCH。
逃生舱:-DKMCU_AGENT_INCREMENTAL=0、-DKMCU_AGENT_REUSE_KV=0、-DKMCU_AGENT_PREFILL=0,三层都能单独回退。
六、诚实边界
- 6201 ms 是
KMCU_AGENT_INCREMENTAL=0的实测值;「120 次前向」的旧路径
只做了执行量推导,没有单独计时。 - 增量分类假设「上下文只追加、不修改」。如果将来加入编辑历史/回溯,这个假设要重新验证。
七、一句话
Agent 的每一步都在重算同一段前缀;把「分类」和「生成」接到同一次 forward 的两个出口上,
再把分类变成增量池化,8 步从 6.2 秒压到 1.7 秒,输出一个 bit 都没变。
对应源码
| 文件 | 关键符号 / 位置 | 支撑本文哪部分 |
|---|---|---|
main/kmcu.c | km_cls_forward_ex()/km_decode_head() | O-2a 一次前向两个出口 |
main/kmcu.c | km_decode_hidden()/km_cls_head_from_sum() | O-2b 增量分类 |
main/kmcu.c | km_agent_loop()的KMCU_AGENT_*开关 | 三层逃生舱 |
tools/patch_cls_head.py | --force-class | 强制走 generate 以覆盖测试路径 |
《Kestrel-MCU 手记》· 全系列 37 篇:在 ESP32-P4 上从零训练并部署 64M 参数 MoE 大模型(5.8 tok/s),源码、权重、训练脚本与全部文章开源可复现。
仓库:https://gitee.com/pei-xiaoguang/kestrel-llm-mcu · 觉得有用欢迎 Star