Nicksxs's Blog

What hurts more, the pain of hard work or the pain of regret?

之前写过几篇 Java Agent 和 Javassist,也单独介绍过 Arthas 的 vmtool。这些内容放在一起看,会发现它们其实一直围绕着同一个问题:

不修改业务源码,能不能在一个 Java 方法执行前后,临时加上一些观察逻辑?

比如有这样一个方法:

1
2
3
4
5
6
7
8
9
public String placeOrder(String user, int quantity) {
if ("error".equals(user)) {
throw new IllegalArgumentException("user cannot be error");
}

validateQuantity(quantity);
String sku = queryInventory(user);
return saveOrder(user, sku, quantity);
}

我们希望知道:

  • 调用时传了什么参数;
  • 正常执行返回了什么;
  • 抛异常时是什么异常;
  • 整个方法以及内部几个调用分别耗时多久。

如果能改源码,加几行日志当然最简单。但在线上排查时,往往既不方便改代码,也不希望为了几行日志重新发布和重启应用。一般现在会使用arthas来查看参数,返回等,不过仔细研究下,ASM、ByteKit 和 Arthas,正好可以看成从底到顶的三种解决层次。

ASM、ByteKit 与 Arthas 的分层关系

简单概括就是:

工具它解决的问题使用者面对的东西
ASM怎样正确读写 class 字节码指令、操作数栈、局部变量、栈帧
ByteKit怎样更方便地表达诊断插桩@AtEnter@AtExit@Binding
Arthas怎样把插桩变成可以直接使用的排障工具watchtracejadreset

这里最容易产生的误解是把它们看成三套互相竞争的技术。实际上,ByteKit 构建在 ASM 之上,而 Arthas 的字节码增强又使用了 ByteKit。它们更像是同一条链路上的不同抽象层。

本文会沿着这条链路,从一个可以运行的例子开始,一层层看清楚它们分别做了什么。

完整示例的源码会在本文末尾直接渲染,本地工程放在:

1
source/code/asm-bytekit-arthas-demo

下面的代码块来自这个目录中的真实文件,不依赖目录页面。示例要求 JDK 8 以上和 Maven 3.6 以上,本文实际使用 JDK 8、ASM 9.9.1、ByteKit 0.1.7 和 Arthas 4.3.2 验证。

先从 class 是怎样被改掉的说起

在看 ASM 的 API 之前,先把 Java 程序从源码到运行的过程简化一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
DemoService.java
|
| javac
v
DemoService.class,也就是一组 byte[]
|
| ClassLoader.defineClass
v
JVM 中的 Class<?> 和方法实现
|
| 解释执行 / JIT 编译
v
机器指令

平时我们关注的是 .java 和对象,字节码工具关注的则是中间那段 byte[]

Java 在 java.lang.instrument 包里提供了 Instrumentation 接口。Agent 可以向它注册一个 ClassFileTransformer

1
instrumentation.addTransformer(transformer, true);

当 JVM 加载类,或者对已加载的类执行 retransformClasses() 时,会调用 Transformer:

1
2
3
4
5
6
7
byte[] transform(
ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer
)

这里最重要的就是最后一个参数和返回值:

1
2
3
4
5
6
7
8
9
JVM 给 Transformer 原始 classfileBuffer
|
| 修改
v
Transformer 返回新的 byte[]
|
| 校验并安装
v
JVM 开始执行增强后的方法

如果 Transformer 返回 null,表示这个类不修改。返回原始数组也能达到类似效果,但按照接口语义,不处理时返回 null 更清楚。

这也是为什么 ASM、Javassist、Byte Buddy 和 ByteKit 虽然 API 完全不同,最后却都能挂到 Java Agent 上:它们最终都在完成同一件事,把一份 class 字节数组转换成另一份 class 字节数组。

premain 和 agentmain 又是什么关系

之前 Agent 示例里使用过 premain

1
2
3
public static void premain(String agentArgs, Instrumentation instrumentation) {
instrumentation.addTransformer(new AsmTimingTransformer(), true);
}

配合 JVM 参数启动:

1
java -javaagent:asm-agent.jar -jar app.jar

premain 在应用 main 方法之前执行,所以特别适合 APM、链路追踪、覆盖率等需要从启动阶段持续工作的工具。

但 Arthas 面对的是一个已经运行的 JVM,它使用的是另一条入口:

1
2
3
public static void agentmain(String agentArgs, Instrumentation instrumentation) {
// 动态挂载之后执行
}

两者拿到的都是 Instrumentation,主要区别只是进入目标 JVM 的时机:

1
2
3
启动时加载:-javaagent -> premain -> main

运行时加载:目标 JVM 已运行 -> Attach API -> agentmain

所以“Java Agent”和“Arthas 动态挂载”并不是两套原理。Arthas 只是把 Agent 从启动参数加载,变成了运行时 Attach。

第一层:直接使用 ASM 改方法

ASM 是一个很底层的字节码操作库。这里的“底层”并不是说它直接操作二进制位,而是说它暴露出来的概念已经非常接近 JVM class 文件:

  • 类名使用 com/example/DemoService 这种 internal name;
  • 方法由名字和 descriptor 一起确定;
  • 方法内容是一条条 JVM 指令;
  • 我们需要关心局部变量、操作数栈和 stack map frame。

为什么方法名还不够

Java 支持方法重载,下面两个方法名字相同:

1
2
placeOrder(String user, int quantity)
placeOrder(String user)

字节码里需要用 descriptor 区分。本文目标方法的 descriptor 是:

1
(Ljava/lang/String;I)Ljava/lang/String;

拆开看并不复杂:

1
2
3
4
5
(
Ljava/lang/String; 第一个参数 String
I 第二个参数 int
)
Ljava/lang/String; 返回值 String

因此 ASM Transformer 里同时匹配类名、方法名和 descriptor:

1
2
3
4
5
private static final String TARGET_CLASS =
"com/nicksxs/bytecode/demo/app/DemoService";
private static final String TARGET_METHOD = "placeOrder";
private static final String TARGET_DESCRIPTOR =
"(Ljava/lang/String;I)Ljava/lang/String;";

这比只匹配方法名稳妥,否则一个类里所有重载方法都可能被增强。

ClassReader、Visitor 和 ClassWriter

ASM 最典型的处理流程是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
ClassReader reader = new ClassReader(classfileBuffer);
ClassWriter writer = new ClassWriter(
reader,
ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS
);

ClassVisitor visitor = new ClassVisitor(Opcodes.ASM9, writer) {
@Override
public MethodVisitor visitMethod(
int access,
String name,
String descriptor,
String signature,
String[] exceptions) {
MethodVisitor mv = super.visitMethod(
access, name, descriptor, signature, exceptions);

if (TARGET_METHOD.equals(name)
&& TARGET_DESCRIPTOR.equals(descriptor)) {
return new TimingAdvice(mv, access, name, descriptor);
}
return mv;
}
};

reader.accept(visitor, ClassReader.EXPAND_FRAMES);
return writer.toByteArray();

可以把它理解成一条流水线:

1
2
3
4
5
6
7
8
9
原始 byte[]
|
ClassReader 解析 class 结构
|
ClassVisitor 访问类、字段和方法
|
MethodVisitor 在目标方法中插入指令
|
ClassWriter 重新生成 byte[]

ASM 使用 Visitor 模式的一个好处是可以边读边写,不一定要把整个 class 转成另一套庞大的对象模型。需要更方便地增删和遍历指令时,也可以使用 ASM Tree API,把类读成 ClassNodeMethodNode

为什么插一行日志也会牵涉操作数栈

源码中的:

1
System.out.println("hello");

落实到字节码,大致是:

1
2
3
GETSTATIC  java/lang/System.out : Ljava/io/PrintStream;
LDC "hello"
INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/String;)V

前两条指令把 PrintStream 对象和字符串压入操作数栈,第三条再把它们消费掉。顺序错了、类型错了或者某条分支上的栈高度不一致,JVM 验证类时就可能抛出 VerifyError

本文示例使用 AdviceAdapter,它在 MethodVisitor 上又封装了一层,提供:

1
2
onMethodEnter()
onMethodExit(int opcode)

我们仍然是在生成 JVM 指令,只是少写了一些容易出错的样板代码。

在方法入口记录参数和开始时间

示例在入口先调用 System.nanoTime(),把结果放进一个新建的局部变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Override
protected void onMethodEnter() {
invokeStatic(SYSTEM_TYPE, NANO_TIME);
startNanosLocal = newLocal(Type.LONG_TYPE);
storeLocal(startNanosLocal);

getStatic(SYSTEM_TYPE, "out", PRINT_STREAM_TYPE);
newInstance(STRING_BUILDER_TYPE);
dup();
invokeConstructor(STRING_BUILDER_TYPE, STRING_BUILDER_INIT);
push("[ASM] enter placeOrder user=");
invokeVirtual(STRING_BUILDER_TYPE, APPEND_STRING);
loadArg(0);
invokeVirtual(STRING_BUILDER_TYPE, APPEND_STRING);
push(", quantity=");
invokeVirtual(STRING_BUILDER_TYPE, APPEND_STRING);
loadArg(1);
invokeVirtual(STRING_BUILDER_TYPE, APPEND_INT);
invokeVirtual(STRING_BUILDER_TYPE, TO_STRING);
invokeVirtual(PRINT_STREAM_TYPE, PRINTLN);
}

这里的 loadArg(0)loadArg(1),最后会变成相应的局部变量加载指令。ASM 知道方法是不是静态方法,因此会帮我们处理 this 占用的第 0 个局部变量槽位。

一个方法不一定只有一个出口

正常返回可能使用:

1
2
3
4
5
6
IRETURN
LRETURN
FRETURN
DRETURN
ARETURN
RETURN

异常还会经过 ATHROW。如果直接找到最后一条指令插日志,很容易漏掉前面的提前返回和异常分支。

AdviceAdapter.onMethodExit() 会在这些退出指令前回调:

1
2
3
4
5
6
7
8
9
10
@Override
protected void onMethodExit(int opcode) {
invokeStatic(SYSTEM_TYPE, NANO_TIME);
loadLocal(startNanosLocal);
math(SUB, Type.LONG_TYPE);
int elapsedLocal = newLocal(Type.LONG_TYPE);
storeLocal(elapsedLocal);

// 后面生成 println("cost=" + elapsed + " ns") 的指令
}

示例还根据 opcode == ATHROW 区分正常返回和显式抛出异常。

这里有个值得注意的细节:只在 ATHROW 前插代码,能捕获方法字节码里显式执行的 ATHROW,却不等同于给整个方法套了一层 try/finally。如果某个子调用抛出异常并直接向外传播,目标方法本身不一定存在一条可见的 ATHROW 指令。真正通用的异常退出增强,需要改写异常表,ByteKit 的 @AtExceptionExit 就封装了这部分处理。

COMPUTE_MAXS 和 COMPUTE_FRAMES 在算什么

每个方法在 class 文件里都需要声明:

  • 最大操作数栈深度;
  • 最大局部变量槽位数量;
  • 分支合流位置的 stack map frame。

插入新指令和新局部变量以后,这些数据可能发生变化。示例让 ClassWriter 自动计算:

1
ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS

这非常适合入门示例,但不是说以后就完全不用理解栈帧。复杂项目里,计算 frame 时可能需要解析类型的共同父类,从而牵涉目标 ClassLoader;不恰当地加载业务类,还可能带来类初始化或类加载隔离问题。Arthas 和 ByteKit 里有不少代码,就是在处理这些工程细节。

运行 ASM 示例

进入示例目录并构建:

1
2
cd source/code/asm-bytekit-arthas-demo
mvn clean package

先运行没有增强的版本:

1
./run.sh baseline 4 10

输出只有应用自己的日志:

1
2
3
4
5
6
demo pid=21257
iterations=4
[APP] order created: user=user-1, sku=SKU-USER-1, quantity=1
[APP] order created: user=user-2, sku=SKU-USER-2, quantity=2
[APP] order created: user=user-3, sku=SKU-USER-3, quantity=3
[APP] failed: user cannot be error

再带上 ASM Agent:

1
./run.sh asm 4 10

可以看到业务源码没有改变,但 placeOrder() 前后已经出现了 Agent 插入的输出:

1
2
3
4
5
6
7
8
9
[ASM agent] installed
[ASM agent] transforming com/nicksxs/bytecode/demo/app/DemoService
[ASM] enter placeOrder user=user-1, quantity=1
[ASM] return placeOrder cost=114967458 ns
[APP] order created: user=user-1, sku=SKU-USER-1, quantity=1
...
[ASM] enter placeOrder user=error, quantity=4
[ASM] throw placeOrder cost=478125 ns
[APP] failed: user cannot be error

这一层已经把字节码增强的基本原理跑通了。不过问题也很明显:为了获取两个参数和一个耗时,我们写了不少类型、descriptor、局部变量和指令生成代码。如果要支持任意方法的参数、返回值、异常和内部调用,代码量会迅速增加。

ByteKit 就是为了解决这一层的重复劳动。

第二层:ByteKit 把字节码操作变成诊断语义

ByteKit 官方对自己的定位很明确:

基于 ASM 提供更高层的字节码处理能力,面向诊断和 APM 领域,而不是一套通用字节码库。

所以 ByteKit 并没有取代 ASM。它做的是把诊断工具经常需要的动作整理成更容易描述的概念。

注入点解决“在哪里执行”

常见注入点包括:

注解对应位置
@AtEnter方法刚进入
@AtExit方法正常返回
@AtExceptionExit方法异常退出
@AtInvoke方法内部调用另一个方法
@AtInvokeException内部调用抛出异常
@AtLine指定源码行
@AtFieldAccess访问字段
@AtSyncEnter / @AtSyncExit进入或退出同步块

ASM 层要自己寻找 RETURNATHROWMethodInsnNode 或行号节点;ByteKit 层可以先说清楚“我要方法退出点”或者“我要内部调用结束点”。

Binding 解决“在这里能拿到什么”

注入点确定以后,还需要把运行时数据交给增强逻辑:

Binding得到的数据
@Binding.This当前对象 this
@Binding.Args方法参数数组
@Binding.Return返回值
@Binding.Throwable抛出的异常
@Binding.MethodName方法名
@Binding.MethodDesc方法 descriptor
@Binding.Field当前对象的指定字段
@Binding.InvokeArgs被调用方法的参数
@Binding.InvokeReturn被调用方法的返回值

本文的 ByteKit 拦截器就变得非常直观:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class DemoInterceptor {

@AtEnter(inline = true)
public static void atEnter(
@Binding.MethodName String methodName,
@Binding.Args Object[] args) {
System.out.println("[ByteKit] enter "
+ methodName + " args=" + Arrays.toString(args));
}

@AtExit(inline = true)
public static void atExit(@Binding.Return Object returnObject) {
System.out.println("[ByteKit] return " + returnObject);
}

@AtExceptionExit(inline = true, onException = Throwable.class)
public static void atExceptionExit(
@Binding.Throwable Throwable throwable) {
System.out.println("[ByteKit] throw "
+ throwable.getClass().getSimpleName()
+ ": " + throwable.getMessage());
}
}

同样是获取参数、返回值和异常,这段代码比直接写 ASM 更接近我们的真实意图。

ByteKit 不是通过反射“监听”方法

看到这些注解,很容易把它想成 Spring AOP 那样的代理调用。实际上 ByteKit 处理的仍然是目标方法本身的字节码。

示例的 Transformer 核心逻辑是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
DefaultInterceptorClassParser parser =
new DefaultInterceptorClassParser();
List<InterceptorProcessor> interceptors =
parser.parse(DemoInterceptor.class);

ClassNode classNode = new ClassNode();
ClassReader reader = AsmUtils.toClassNode(
classfileBuffer, classNode);

for (MethodNode methodNode : classNode.methods) {
if (!"placeOrder".equals(methodNode.name)) {
continue;
}

MethodProcessor methodProcessor =
new MethodProcessor(classNode, methodNode);
for (InterceptorProcessor interceptor : interceptors) {
interceptor.process(methodProcessor);
}
}

return AsmUtils.toBytes(classNode, loader, reader);

它大致经历四步:

  1. 解析拦截器上的 @AtEnter@AtExit 和 Binding;
  2. 用 ASM Tree API 把原始 class 读成 ClassNode
  3. 找到目标 MethodNode,由 MethodProcessor 插入指令;
  4. 再把修改后的节点树写回 byte[]

ByteKit 的价值不是绕过字节码,而是把一批成熟的字节码生成规则封装起来。

inline 到底内联了什么

示例的注解都设置了:

1
inline = true

可以把它理解成:增强后并不是简单地在业务方法里调用:

1
DemoInterceptor.atEnter(...);

而是尽量把拦截器方法里的指令复制到目标方法中。这样做可以减少一次回调调用,也降低目标 ClassLoader 必须能加载拦截器类的要求。

不过内联也不是免费午餐。增强逻辑太大,会让目标方法字节码膨胀;增强代码引用的其他类型,仍然需要目标类加载器能够解析。因此诊断插桩通常应该保持短小,把复杂状态管理放在稳定的桥接 API 后面。

为什么 ByteKit 有防重复增强

诊断工具可能在同一个类上连续执行 watchtracemonitor。如果每次都无条件再插一遍入口和出口代码,目标方法会越来越大,输出也会重复。

ByteKit 提供了位置过滤机制,可以检查某个注入点是否已经存在。Arthas 的 Enhancer 还会专门检查目标方法中是否已经调用 SpyAPI.atEnteratExitatExceptionExit,避免重复插入同类探针。

这是 ASM 示例和真正工程化诊断工具之间很典型的差距:基础 API 能完成一次修改,框架还要负责多次修改之间的组合、去重和恢复。

运行 ByteKit 示例

执行:

1
./run.sh bytekit 4 10

本文实际验证得到:

1
2
3
4
5
6
7
8
9
[ByteKit agent] installed
[ByteKit agent] transforming com/nicksxs/bytecode/demo/app/DemoService
[ByteKit] enter placeOrder args=[user-1, 1]
[ByteKit] return order created: user=user-1, sku=SKU-USER-1, quantity=1
[APP] order created: user=user-1, sku=SKU-USER-1, quantity=1
...
[ByteKit] enter placeOrder args=[error, 4]
[ByteKit] throw IllegalArgumentException: user cannot be error
[APP] failed: user cannot be error

到这里,我们已经用两种方式修改了同一个方法:

1
2
3
ASM:自己生成每一段参数加载、时间计算和输出指令

ByteKit:声明注入点和需要绑定的数据,由 ByteKit 生成指令

但这两种方式仍然需要我们自己写 Agent、打包、指定目标类并启动应用。线上排障真正想要的是:输入一条命令,就临时观察某个方法。

这就是 Arthas 所处的第三层。

第三层:Arthas 把动态增强变成排障命令

先启动不带任何 Agent 的 Demo,并让它持续执行:

1
./run.sh app

另开一个终端,启动 Arthas:

1
2
curl -O https://arthas.aliyun.com/arthas-boot.jar
java -jar arthas-boot.jar --use-version 4.3.2

选择:

1
com.nicksxs.bytecode.demo.app.DemoApplication

这一步之后,Arthas Agent 已经通过 Attach API 进入目标 JVM,并拿到了 Instrumentation

第一步先确认类和 ClassLoader

1
sc -d com.nicksxs.bytecode.demo.app.DemoService

本文测试时的关键输出是:

1
2
3
4
class-info       com.nicksxs.bytecode.demo.app.DemoService
code-source .../asm-bytekit-arthas-demo/target/classes/
class-loader +-sun.misc.Launcher$AppClassLoader@73d16e93
classLoaderHash 73d16e93

这一步并不多余。同一个类名可能被多个 ClassLoader 各加载一份,特别是在 Tomcat、插件系统和热部署环境里。如果不先确认 ClassLoader,后面的命令可能命中错误的类,或者提示匹配到了多个类。

也可以继续查看方法:

1
sm com.nicksxs.bytecode.demo.app.DemoService

watch 观察一次方法的输入和输出

执行:

1
2
watch com.nicksxs.bytecode.demo.app.DemoService placeOrder \
'{params,returnObj,throwExp}' -x 2 -n 4

几个参数分别表示:

参数含义
params当前调用的参数数组
returnObj正常返回值
throwExp异常,没有异常时为 null
-x 2对象展开深度为 2
-n 4命中 4 次后自动退出

实际输出之一:

1
2
3
4
5
6
7
8
9
method=com.nicksxs.bytecode.demo.app.DemoService.placeOrder location=AtExit
[cost=123.336292ms] result=@ArrayList[
@Object[][
@String[user-25],
@Integer[25],
],
@String[order created: user=user-25, sku=SKU-USER-25, quantity=25],
null,
]

可以看到,watch 输出里的 location=AtExit,和 ByteKit 的 @AtExit 已经能对应起来。这里出现的 paramsreturnObjthrowExp,本质上也来自方法入口、正常出口和异常出口处插入的探针。

trace 为什么能看到内部调用

执行:

1
2
trace com.nicksxs.bytecode.demo.app.DemoService placeOrder \
'#cost > 0' -n 2

本文实测输出:

1
2
3
4
`---[116.058125ms] DemoService:placeOrder()
+---[25.93% 30.089459ms ] DemoService:validateQuantity() #10
+---[38.65% 44.852583ms ] DemoService:queryInventory() #11
`---[35.08% 40.714208ms ] DemoService:saveOrder() #12

异常分支则是:

1
2
3
`---[0.902ms] DemoService:placeOrder() [throws Exception]
`---throw:java.lang.IllegalArgumentException #7
[user cannot be error]

watch 主要关心目标方法的入口和出口,trace 还需要在方法内部的调用指令前后插入探针。对应到 ByteKit,就是 @AtInvoke、调用完成和调用异常等位置。

Arthas 源码里的 EnhancerisTracing 为真时,会加入 SpyTraceInterceptor,遍历目标方法里的调用指令,并为每个调用注册 trace listener。最终看到的调用树,是这些 before/after/exception 事件按线程和调用层级组合出来的。

jad 看到的是 JVM 当前认识的类

1
jad com.nicksxs.bytecode.demo.app.DemoService placeOrder

它会把目标 JVM 中的 class 反编译出来,而不是去源码目录读取 DemoService.java。这在排查“代码明明改了,线上为什么还是旧逻辑”“同名 jar 到底加载了哪一份”时很有用。

本文 Demo 得到:

1
2
3
4
5
6
7
8
public String placeOrder(String user, int quantity) {
if ("error".equals(user)) {
throw new IllegalArgumentException("user cannot be error");
}
this.validateQuantity(quantity);
String sku = this.queryInventory(user);
return this.saveOrder(user, sku, quantity);
}

需要注意,watch -ntrace -n 达到次数退出后,对应 listener 会结束,Arthas 也可能重新组织该类上的增强。因此 jad 展示的是执行命令当时 JVM 中有效的字节码状态,不应该把它简单理解成磁盘源码查看器。

诊断结束为什么要 reset

1
reset com.nicksxs.bytecode.demo.app.DemoService

watchtracemonitor 不是在 JVM 外面被动偷看,它们会修改目标类。诊断结束后执行 reset,可以移除 Arthas 对这个类的增强,让方法回到未观测状态。

官方文档也特别提醒,线上使用增强命令时要缩小类和方法匹配范围,并在结束后执行 resetstop

从源码看 Arthas 是怎样使用 ByteKit 的

如果只看命令,很容易觉得 Arthas 内部可能有一套完全不同的魔法。直接看 com.taobao.arthas.core.advisor.Enhancer,链路就很清楚了。

首先,Enhancer 自己就是:

1
public class Enhancer implements ClassFileTransformer

它收到 classfileBuffer 后,用 ByteKit 提供的 AsmUtils 读取:

1
2
3
ClassNode classNode = new ClassNode(Opcodes.ASM9);
ClassReader classReader = AsmUtils.toClassNode(
classfileBuffer, classNode);

然后解析 Arthas 自己定义的拦截器:

1
2
3
4
5
6
7
8
9
DefaultInterceptorClassParser parser =
new DefaultInterceptorClassParser();

interceptorProcessors.addAll(
parser.parse(SpyInterceptor1.class));
interceptorProcessors.addAll(
parser.parse(SpyInterceptor2.class));
interceptorProcessors.addAll(
parser.parse(SpyInterceptor3.class));

如果是 trace,还会加入 SpyTraceInterceptor。找到匹配方法后,再执行:

1
2
3
4
5
6
MethodProcessor methodProcessor =
new MethodProcessor(classNode, methodNode, locationFilter);

for (InterceptorProcessor interceptor : interceptorProcessors) {
interceptor.process(methodProcessor);
}

最后重新生成 class:

1
2
3
byte[] enhanced = AsmUtils.toBytes(
classNode, inClassLoader, classReader);
return enhanced;

这和我们写的 ByteKit Demo 几乎是同一条主干,只是 Arthas 多了大量工程能力:

  • 类名、方法名和 ClassLoader 匹配;
  • 多命令 listener 管理;
  • 防止重复插桩;
  • trace 内部调用注册;
  • CGLIB 构造函数等特殊 class 的处理;
  • 原始类和增强状态管理;
  • 命令退出后的恢复;
  • 把事件送回 Arthas 自己的 ClassLoader。

因此可以把它们的关系写成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
用户输入 watch / trace
|
v
Arthas 创建 Matcher 和 AdviceListener
|
v
Enhancer 作为 ClassFileTransformer
|
v
ByteKit 把 AtEnter / AtExit / AtInvoke 转换为增强动作
|
v
ASM 读取、修改并写回 class byte[]
|
v
Instrumentation.retransformClasses()
|
v
JVM 执行插入了 SpyAPI 调用的新方法

SpyAPI 为什么要放在特殊位置

Arthas 自己的核心类由独立 ClassLoader 加载,而业务类可能来自 AppClassLoader、WebAppClassLoader 或自定义 ClassLoader。业务方法增强后如果直接引用 Arthas Core 里的某个类,业务 ClassLoader 不一定能找到它。

Arthas 使用 java.arthas.SpyAPI 作为一座稳定的桥:

1
2
3
4
5
6
7
8
9
业务 ClassLoader 中被增强的方法
|
| SpyAPI.atEnter / atExit
v
可被目标类访问的 SpyAPI
|
| 转发事件
v
Arthas AdviceListener

Enhancer.transform() 的开头还会检查目标 ClassLoader 能否加载 SpyAPI,不能加载就放弃增强。这正是字节码工具里一个很现实的问题:生成一条方法调用指令并不难,难的是确保这条指令在目标类的加载环境里永远能解析成功。

我们的小 Demo 把 Agent 和应用都放在同一个 classpath 中,所以感觉不到这种复杂度。真正的诊断工具必须认真处理 ClassLoader 边界。

ASM、ByteKit、Arthas 和 Javassist 怎么选

之前的文章使用 Javassist,通过字符串形式插入代码:

1
2
method.insertBefore("System.out.println($args);");
method.insertAfter("System.out.println($_);");

它的优势是接近 Java 源码,入门很直观。把它和本文三个层次放在一起,可以这样理解:

工具最适合的场景优点需要承担的复杂度
Javassist快速生成或修改类、简单 Agent接近源码表达,容易上手字符串代码、类型和复杂控制流处理
ASM框架底层、需要精确控制和低开销能控制每条指令,生态基础扎实栈、frame、descriptor、异常表
ByteKitAPM、诊断类插桩框架注入点和 Binding 丰富,适合复用仍要负责 Agent、匹配、生命周期
Arthas直接排查运行中的 Java 应用开箱即用、动态挂载、命令完整需要控制线上影响和匹配范围

如果只是想确认线上某个参数或耗时,优先用 Arthas,而不是现场写 ASM。

如果在开发自己的诊断或 APM 能力,ByteKit 能减少大量通用插桩工作。

如果 ByteKit 没有提供需要的注入位置,或者正在实现更底层的字节码框架,再下沉到 ASM。

理解 ASM 的意义,不是以后所有事情都手写指令,而是知道上层工具究竟替我们处理了哪些问题。

retransform 也不是任意热更新

Instrumentation.retransformClasses() 可以替换方法实现,但它不是完整的类结构热更新。通常不能在重转换时随意:

  • 新增或删除字段;
  • 新增或删除方法;
  • 修改方法签名;
  • 改变继承关系;
  • 改变 nest host、record component 等类结构属性。

watchtrace 只在已有方法内部增加指令,没有改变类的 schema,所以适合使用 retransform。

这也解释了为什么 Arthas 的 mcretransform 能修改已有方法逻辑,却不能把一个全新的字段热塞进已经加载的类。限制来自 JVM 类重定义规则,不是 Arthas 少实现了一个参数。

多个 Agent 同时工作时发生什么

一个 JVM 可以注册多个 ClassFileTransformer。类加载或重转换时,它们会按照 JVM 规定的顺序依次收到字节码,后面的 Transformer 看到的可能已经是前面修改过的结果。

1
2
3
4
5
6
7
8
9
原始 class
|
APM Transformer
|
覆盖率 Transformer
|
Arthas Transformer
|
最终 class

如果某个工具:

  • 生成了不合法的 frame;
  • 没有保留其他工具插入的指令;
  • 重复增强同一位置;
  • 对异常表和构造函数处理不兼容;

就可能出现 VerifyErrorClassFormatError、方法过大,或者某个 Agent 的增强被另一个覆盖。

因此线上已经运行 SkyWalking、Pinpoint、JaCoCo 或安全 Agent 时,使用 Arthas 增强命令要更加谨慎。先精确匹配一个方法、限制命中次数,再观察是否存在兼容性问题。

字节码增强的性能开销在哪里

“没有重启应用”不等于“完全没有运行影响”。主要开销可以分成三部分。

重转换本身的开销

类被 retransform 后,JVM 之前为它生成的 JIT 编译代码可能失效,相关方法需要重新解释执行并再次编译。因此刚增强或刚 reset 后,可能看到 JIT 编译线程活跃,方法性能也可能短暂波动。

每次调用探针的开销

入口、出口和内部调用都会多执行一些指令。trace 的粒度比只观察一次出口的 watch 更细,通常也会有更多事件。

输出和表达式计算的开销

把一个很大的对象用 -x 5 展开、执行复杂 OGNL 表达式、对高频方法持续输出,开销往往比那几条入口探针本身更大。

所以线上使用时建议:

1
2
3
4
5
精确类名 > 大范围通配符
精确方法名 > 整个类所有方法
带 -n 次数限制 > 一直运行
较小的 -x 展开深度 > 深度遍历对象图
带条件表达式 > 输出所有请求

例如只观察慢于 100 毫秒的调用:

1
2
watch com.nicksxs.bytecode.demo.app.DemoService placeOrder \
'{params,returnObj,throwExp}' '#cost > 100' -x 2 -n 5

把整条链路再走一遍

现在回到最开始的问题:不修改源码,怎样观察 placeOrder()

使用 ASM

我们自己完成:

  1. Agent 注册 Transformer;
  2. 匹配类名、方法名和 descriptor;
  3. 找到方法入口和每一个退出指令;
  4. 管理局部变量和操作数栈;
  5. 生成新的 class 字节数组。

使用 ByteKit

仍然需要 Agent 和 Transformer,但注入逻辑变成:

1
2
3
@AtEnter + @Binding.Args
@AtExit + @Binding.Return
@AtExceptionExit + @Binding.Throwable

ByteKit 再把这些声明转换为 ASM 指令。

使用 Arthas

只需要输入:

1
2
watch ...
trace ...

Arthas 负责动态 Attach、类和方法匹配、ByteKit 插桩、listener 管理、结果展示以及恢复。

所以三者最准确的关系并不是:

1
ASM vs ByteKit vs Arthas

而是:

1
2
3
4
Arthas
使用 ByteKit 表达诊断插桩
使用 ASM 操作字节码
通过 Instrumentation 交给 JVM

理解到这一层以后,再看 watch 输出里的 AtExittrace 的内部调用树,以及 Agent 里的 ClassFileTransformer,它们就不再是几个零散的名词,而是同一条执行链路上的不同环节。

完整工程源码

前面为了讲清原理,只截取了每个环节最关键的代码。下面通过 Hexo 的 include_code 标签直接加载本地工程文件,博客页面会把它们渲染成完整的代码块。

工程结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
asm-bytekit-arthas-demo/
├── pom.xml
├── run.sh
├── arthas-commands.txt
└── src/main/java/com/nicksxs/bytecode/demo/
├── app/
│ ├── DemoApplication.java
│ └── DemoService.java
├── asm/
│ ├── AsmAgent.java
│ └── AsmTimingTransformer.java
└── bytekit/
├── ByteKitAgent.java
├── ByteKitTransformer.java
└── DemoInterceptor.java

Maven 配置

pom.xml 中除了 ASM 和 ByteKit 依赖,还通过 maven-jar-plugin 分别生成两个带不同 Premain-Class 的 Agent JAR。

pom.xmlview raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>com.nicksxs</groupId>
<artifactId>asm-bytekit-arthas-demo</artifactId>
<version>1.0.0</version>

<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<asm.version>9.9.1</asm.version>
<bytekit.version>0.1.7</bytekit.version>
</properties>

<dependencies>
<dependency>
<groupId>org.ow2.asm</groupId>
<artifactId>asm</artifactId>
<version>${asm.version}</version>
</dependency>
<dependency>
<groupId>org.ow2.asm</groupId>
<artifactId>asm-commons</artifactId>
<version>${asm.version}</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>bytekit-core</artifactId>
<version>${bytekit.version}</version>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.1</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.4.2</version>
<executions>
<execution>
<id>asm-agent</id>
<phase>package</phase>
<goals>
<goal>jar</goal>
</goals>
<configuration>
<classifier>asm-agent</classifier>
<archive>
<manifestEntries>
<Premain-Class>com.nicksxs.bytecode.demo.asm.AsmAgent</Premain-Class>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</archive>
</configuration>
</execution>
<execution>
<id>bytekit-agent</id>
<phase>package</phase>
<goals>
<goal>jar</goal>
</goals>
<configuration>
<classifier>bytekit-agent</classifier>
<archive>
<manifestEntries>
<Premain-Class>com.nicksxs.bytecode.demo.bytekit.ByteKitAgent</Premain-Class>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</archive>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.8.1</version>
<executions>
<execution>
<id>copy-runtime-dependencies</id>
<phase>package</phase>
<goals>
<goal>copy-dependencies</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

示例应用

DemoApplication 会循环调用 DemoService.placeOrder()。每四次调用构造一次异常,方便同时验证正常返回和异常退出。

DemoApplication.javaview raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package com.nicksxs.bytecode.demo.app;

import java.lang.management.ManagementFactory;

public class DemoApplication {

public static void main(String[] args) throws Exception {
int iterations = args.length > 0 ? Integer.parseInt(args[0]) : 5;
long pauseMillis = args.length > 1 ? Long.parseLong(args[1]) : 500L;

System.out.println("demo pid=" + currentPid());
System.out.println("iterations=" + (iterations == 0 ? "infinite" : iterations));

DemoService service = new DemoService();
int index = 1;
while (iterations == 0 || index <= iterations) {
String user = index % 4 == 0 ? "error" : "user-" + index;
try {
String result = service.placeOrder(user, index);
System.out.println("[APP] " + result);
} catch (Exception e) {
System.out.println("[APP] failed: " + e.getMessage());
}
index++;
Thread.sleep(pauseMillis);
}
}

private static String currentPid() {
String runtimeName = ManagementFactory.getRuntimeMXBean().getName();
int separator = runtimeName.indexOf('@');
return separator > 0 ? runtimeName.substring(0, separator) : runtimeName;
}
}
DemoService.javaview raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package com.nicksxs.bytecode.demo.app;

public class DemoService {

public String placeOrder(String user, int quantity) {
if ("error".equals(user)) {
throw new IllegalArgumentException("user cannot be error");
}

validateQuantity(quantity);
String sku = queryInventory(user);
return saveOrder(user, sku, quantity);
}

private void validateQuantity(int quantity) {
sleep(20L);
if (quantity <= 0) {
throw new IllegalArgumentException("quantity must be positive");
}
}

private String queryInventory(String user) {
sleep(40L);
return "SKU-" + user.toUpperCase();
}

private String saveOrder(String user, String sku, int quantity) {
sleep(30L);
return "order created: user=" + user + ", sku=" + sku + ", quantity=" + quantity;
}

private void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("interrupted", e);
}
}
}

ASM Agent

Agent 入口只负责注册 Transformer,具体的类匹配和指令生成都在 AsmTimingTransformer 中。

AsmAgent.javaview raw
1
2
3
4
5
6
7
8
9
10
11
package com.nicksxs.bytecode.demo.asm;

import java.lang.instrument.Instrumentation;

public class AsmAgent {

public static void premain(String agentArgs, Instrumentation instrumentation) {
System.out.println("[ASM agent] installed");
instrumentation.addTransformer(new AsmTimingTransformer(), true);
}
}
AsmTimingTransformer.javaview raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
package com.nicksxs.bytecode.demo.asm;

import java.io.PrintStream;
import java.lang.instrument.ClassFileTransformer;
import java.security.ProtectionDomain;

import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.Type;
import org.objectweb.asm.commons.AdviceAdapter;
import org.objectweb.asm.commons.Method;

public class AsmTimingTransformer implements ClassFileTransformer {

private static final String TARGET_CLASS = "com/nicksxs/bytecode/demo/app/DemoService";
private static final String TARGET_METHOD = "placeOrder";
private static final String TARGET_DESCRIPTOR = "(Ljava/lang/String;I)Ljava/lang/String;";

@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) {
if (!TARGET_CLASS.equals(className)) {
return null;
}

System.out.println("[ASM agent] transforming " + className);
ClassReader reader = new ClassReader(classfileBuffer);
ClassWriter writer = new ClassWriter(reader, ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
ClassVisitor visitor = new ClassVisitor(Opcodes.ASM9, writer) {
@Override
public MethodVisitor visitMethod(int access, String name, String descriptor, String signature,
String[] exceptions) {
MethodVisitor methodVisitor = super.visitMethod(access, name, descriptor, signature, exceptions);
if (!TARGET_METHOD.equals(name) || !TARGET_DESCRIPTOR.equals(descriptor)) {
return methodVisitor;
}
return new TimingAdvice(methodVisitor, access, name, descriptor);
}
};
reader.accept(visitor, ClassReader.EXPAND_FRAMES);
return writer.toByteArray();
}

private static final class TimingAdvice extends AdviceAdapter {

private static final Type SYSTEM_TYPE = Type.getType(System.class);
private static final Type PRINT_STREAM_TYPE = Type.getType(PrintStream.class);
private static final Type STRING_BUILDER_TYPE = Type.getType(StringBuilder.class);
private static final Type STRING_TYPE = Type.getType(String.class);

private static final Method NANO_TIME = new Method("nanoTime", Type.LONG_TYPE, new Type[0]);
private static final Method STRING_BUILDER_INIT = new Method("<init>", Type.VOID_TYPE, new Type[0]);
private static final Method APPEND_STRING = new Method("append", STRING_BUILDER_TYPE,
new Type[] { STRING_TYPE });
private static final Method APPEND_INT = new Method("append", STRING_BUILDER_TYPE,
new Type[] { Type.INT_TYPE });
private static final Method APPEND_LONG = new Method("append", STRING_BUILDER_TYPE,
new Type[] { Type.LONG_TYPE });
private static final Method TO_STRING = new Method("toString", STRING_TYPE, new Type[0]);
private static final Method PRINTLN = new Method("println", Type.VOID_TYPE, new Type[] { STRING_TYPE });

private int startNanosLocal;

private TimingAdvice(MethodVisitor methodVisitor, int access, String name, String descriptor) {
super(Opcodes.ASM9, methodVisitor, access, name, descriptor);
}

@Override
protected void onMethodEnter() {
invokeStatic(SYSTEM_TYPE, NANO_TIME);
startNanosLocal = newLocal(Type.LONG_TYPE);
storeLocal(startNanosLocal);

getStatic(SYSTEM_TYPE, "out", PRINT_STREAM_TYPE);
newInstance(STRING_BUILDER_TYPE);
dup();
invokeConstructor(STRING_BUILDER_TYPE, STRING_BUILDER_INIT);
push("[ASM] enter placeOrder user=");
invokeVirtual(STRING_BUILDER_TYPE, APPEND_STRING);
loadArg(0);
invokeVirtual(STRING_BUILDER_TYPE, APPEND_STRING);
push(", quantity=");
invokeVirtual(STRING_BUILDER_TYPE, APPEND_STRING);
loadArg(1);
invokeVirtual(STRING_BUILDER_TYPE, APPEND_INT);
invokeVirtual(STRING_BUILDER_TYPE, TO_STRING);
invokeVirtual(PRINT_STREAM_TYPE, PRINTLN);
}

@Override
protected void onMethodExit(int opcode) {
invokeStatic(SYSTEM_TYPE, NANO_TIME);
loadLocal(startNanosLocal);
math(SUB, Type.LONG_TYPE);
int elapsedLocal = newLocal(Type.LONG_TYPE);
storeLocal(elapsedLocal);

getStatic(SYSTEM_TYPE, "out", PRINT_STREAM_TYPE);
newInstance(STRING_BUILDER_TYPE);
dup();
invokeConstructor(STRING_BUILDER_TYPE, STRING_BUILDER_INIT);
push(opcode == ATHROW ? "[ASM] throw placeOrder cost=" : "[ASM] return placeOrder cost=");
invokeVirtual(STRING_BUILDER_TYPE, APPEND_STRING);
loadLocal(elapsedLocal);
invokeVirtual(STRING_BUILDER_TYPE, APPEND_LONG);
push(" ns");
invokeVirtual(STRING_BUILDER_TYPE, APPEND_STRING);
invokeVirtual(STRING_BUILDER_TYPE, TO_STRING);
invokeVirtual(PRINT_STREAM_TYPE, PRINTLN);
}
}
}

ByteKit Agent

ByteKit 版本同样由 Agent 注册 Transformer,但插入点和运行时数据改用注解与 Binding 描述。

ByteKitAgent.javaview raw
1
2
3
4
5
6
7
8
9
10
11
package com.nicksxs.bytecode.demo.bytekit;

import java.lang.instrument.Instrumentation;

public class ByteKitAgent {

public static void premain(String agentArgs, Instrumentation instrumentation) {
System.out.println("[ByteKit agent] installed");
instrumentation.addTransformer(new ByteKitTransformer(), true);
}
}
ByteKitTransformer.javaview raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package com.nicksxs.bytecode.demo.bytekit;

import java.lang.instrument.ClassFileTransformer;
import java.security.ProtectionDomain;
import java.util.List;

import com.alibaba.bytekit.asm.MethodProcessor;
import com.alibaba.bytekit.asm.interceptor.InterceptorProcessor;
import com.alibaba.bytekit.asm.interceptor.parser.DefaultInterceptorClassParser;
import com.alibaba.bytekit.utils.AsmUtils;
import com.alibaba.deps.org.objectweb.asm.ClassReader;
import com.alibaba.deps.org.objectweb.asm.tree.ClassNode;
import com.alibaba.deps.org.objectweb.asm.tree.MethodNode;

public class ByteKitTransformer implements ClassFileTransformer {

private static final String TARGET_CLASS = "com/nicksxs/bytecode/demo/app/DemoService";
private static final String TARGET_METHOD = "placeOrder";

@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) {
if (!TARGET_CLASS.equals(className)) {
return null;
}

try {
System.out.println("[ByteKit agent] transforming " + className);
DefaultInterceptorClassParser parser = new DefaultInterceptorClassParser();
List<InterceptorProcessor> interceptors = parser.parse(DemoInterceptor.class);

ClassNode classNode = new ClassNode();
ClassReader reader = AsmUtils.toClassNode(classfileBuffer, classNode);
for (MethodNode methodNode : classNode.methods) {
if (!TARGET_METHOD.equals(methodNode.name)) {
continue;
}
MethodProcessor methodProcessor = new MethodProcessor(classNode, methodNode);
for (InterceptorProcessor interceptor : interceptors) {
interceptor.process(methodProcessor);
}
}
return AsmUtils.toBytes(classNode, loader, reader);
} catch (Throwable throwable) {
throwable.printStackTrace(System.err);
return null;
}
}
}
DemoInterceptor.javaview raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package com.nicksxs.bytecode.demo.bytekit;

import java.util.Arrays;

import com.alibaba.bytekit.asm.binding.Binding;
import com.alibaba.bytekit.asm.interceptor.annotation.AtEnter;
import com.alibaba.bytekit.asm.interceptor.annotation.AtExceptionExit;
import com.alibaba.bytekit.asm.interceptor.annotation.AtExit;

public class DemoInterceptor {

@AtEnter(inline = true)
public static void atEnter(@Binding.MethodName String methodName, @Binding.Args Object[] args) {
System.out.println("[ByteKit] enter " + methodName + " args=" + Arrays.toString(args));
}

@AtExit(inline = true)
public static void atExit(@Binding.Return Object returnObject) {
System.out.println("[ByteKit] return " + returnObject);
}

@AtExceptionExit(inline = true, onException = Throwable.class)
public static void atExceptionExit(@Binding.Throwable Throwable throwable) {
System.out.println("[ByteKit] throw " + throwable.getClass().getSimpleName()
+ ": " + throwable.getMessage());
}
}

运行脚本和 Arthas 命令

run.sh 把原始应用、ASM Agent、ByteKit Agent 和 Arthas 目标应用四种运行方式放在了一起。

run.shview raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#!/usr/bin/env bash
set -euo pipefail

ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
MODE="${1:-baseline}"
ITERATIONS="${2:-5}"
PAUSE_MILLIS="${3:-300}"
MVN_BIN="${MVN_BIN:-mvn}"
JAVA_BIN="${JAVA_BIN:-java}"

if [[ ! -d "$ROOT_DIR/target/classes" ]]; then
if ! command -v "$MVN_BIN" >/dev/null 2>&1; then
echo "mvn was not found. Install Maven or set MVN_BIN to its absolute path." >&2
exit 1
fi
"$MVN_BIN" -q -f "$ROOT_DIR/pom.xml" package
fi

CLASSPATH="$ROOT_DIR/target/classes:$ROOT_DIR/target/dependency/*"
MAIN_CLASS="com.nicksxs.bytecode.demo.app.DemoApplication"
VERSION="1.0.0"

case "$MODE" in
baseline)
exec "$JAVA_BIN" -cp "$CLASSPATH" "$MAIN_CLASS" "$ITERATIONS" "$PAUSE_MILLIS"
;;
asm)
exec "$JAVA_BIN" \
-javaagent:"$ROOT_DIR/target/asm-bytekit-arthas-demo-$VERSION-asm-agent.jar" \
-cp "$CLASSPATH" "$MAIN_CLASS" "$ITERATIONS" "$PAUSE_MILLIS"
;;
bytekit)
exec "$JAVA_BIN" \
-javaagent:"$ROOT_DIR/target/asm-bytekit-arthas-demo-$VERSION-bytekit-agent.jar" \
-cp "$CLASSPATH" "$MAIN_CLASS" "$ITERATIONS" "$PAUSE_MILLIS"
;;
app)
exec "$JAVA_BIN" -cp "$CLASSPATH" "$MAIN_CLASS" 0 1000
;;
*)
echo "usage: $0 {baseline|asm|bytekit|app} [iterations] [pauseMillis]" >&2
exit 1
;;
esac
arthas-commands.txtview raw
1
2
3
4
5
6
7
8
# Run ./run.sh app first, then attach Arthas to DemoApplication.

sc -d com.nicksxs.bytecode.demo.app.DemoService
sm com.nicksxs.bytecode.demo.app.DemoService
watch com.nicksxs.bytecode.demo.app.DemoService placeOrder '{params,returnObj,throwExp}' -x 2 -n 4
trace com.nicksxs.bytecode.demo.app.DemoService placeOrder '#cost > 0' -n 3
jad com.nicksxs.bytecode.demo.app.DemoService placeOrder
reset com.nicksxs.bytecode.demo.app.DemoService

完整验证清单

示例目录下依次执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
cd source/code/asm-bytekit-arthas-demo

# 编译应用和两个 Agent
mvn clean package

# 1. 原始应用
./run.sh baseline 4 10

# 2. ASM 增强
./run.sh asm 4 10

# 3. ByteKit 增强
./run.sh bytekit 4 10

# 4. 为 Arthas 保持应用运行
./run.sh app

另一个终端中:

1
2
curl -O https://arthas.aliyun.com/arthas-boot.jar
java -jar arthas-boot.jar --use-version 4.3.2

挂载后依次执行:

1
2
3
4
5
6
7
8
9
10
11
sc -d com.nicksxs.bytecode.demo.app.DemoService

watch com.nicksxs.bytecode.demo.app.DemoService placeOrder \
'{params,returnObj,throwExp}' -x 2 -n 4

trace com.nicksxs.bytecode.demo.app.DemoService placeOrder \
'#cost > 0' -n 3

jad com.nicksxs.bytecode.demo.app.DemoService placeOrder

reset com.nicksxs.bytecode.demo.app.DemoService

到这里,ASM、ByteKit 和 Arthas 的三个层次就都不只是概念,而是可以在同一个业务方法上亲手验证的结果了。

参考资料

前几天看到一个挺夸张的结果:

FreeToken 在一台只有 8GB 显存的 RTX 4060 Laptop 上运行 Qwen3.6-35B-A3B,解码速度达到了 39.3 tok/s。

第一反应肯定是:35B 模型只算 4bit 权重,理论上也要 17.5GB 左右,8GB 显存怎么可能放得下?

后来翻了下 FreeToken 的论文源码,又对比了下 llama.cpp,发现这个问题里最容易混淆的是两个完全不同的概念:

  • 模型能不能全部放进显存
  • 生成一个 token 时,到底需要计算多少参数

FreeToken 并没有把 35B 参数凭空塞进 8GB 显存里。它做的是把完整的 MoE 专家权重留在内存,把 GPU 显存变成一个会不断换入、换出的专家缓存,再让 CPU 和 GPU 一起处理缓存没有命中的专家。

先把结论用一句话说出来:

MoE 让“大模型每次只用一小部分参数”成为可能,FreeToken 解决的则是“这一小部分参数怎样及时出现在最合适的计算设备上”。

这篇就从这里展开。

先纠正一个容易混淆的模型

之前讨论的是 Qwen3.8-27B,但是 FreeToken 论文里 8GB 显存运行 35B 的案例其实是:

1
Qwen3.6-35B-A3B

这两个模型虽然数字接近,结构却完全不同。

模型结构总参数每个 token 激活的参数
Qwen3.8-27BDense27B接近全部 27B
Qwen3.6-35B-A3BMoE35B约 3B

Qwen3.8-27B 的官方模型卡明确写的是 Dense 模型。它有 64 层,每层的 FFN 都需要参与计算,没有“256 个专家只挑 8 个”这回事。

Qwen3.6-35B-A3B 则有 40 个 MoE 层,每层包含 256 个路由专家。一个 token 只会选择其中 8 个,再加上 1 个始终参与计算的共享专家。

名字里的:

1
2
3
35B-A3B
│ └── 每个 token 大约激活 3B 参数
└────── 模型一共仍然有 35B 参数

A3B 只是在说一次前向计算经过了多少参数,并不是模型文件只有 3B,也不是内存只需要保存 3B。

Dense 和 MoE 的计算路径

Dense 像是一整栋楼只有一条通道,每次都要从头走到尾。MoE 则像每层都有很多条支路,路由器会根据当前 token 选择其中几条。

这带来了两个结果:

  • 计算量可以接近一个 3B 模型
  • 完整的 35B 权重仍然必须放在某个地方

MoE 解决的是计算稀疏,并没有自动解决存储问题。FreeToken 真正处理的就是后半个问题。

35B 模型的内存账应该怎么算

先做一个很粗略的计算。假设不考虑量化元数据、对齐和少量高精度权重:

权重精度每个参数占用35B 参数的理论大小
BF16 / FP162 bytes70GB
FP8 / INT81 byte35GB
4bit0.5 byte17.5GB

所以即使用 4bit,完整模型也明显超过 8GB。实际运行时还要额外留出:

  • KV Cache
  • 中间激活
  • CUDA Graph 和算子工作区
  • 视觉编码器或者其他非专家权重
  • 桌面环境占用的显存

因此“8GB 运行 35B”绝对不能理解为“35B 的所有权重都常驻 8GB 显存”。

论文中的 4060 Laptop 机器实际有:

1
2
3
4
GPU: RTX 4060 Laptop,8GB VRAM
内存: 32GiB LPDDR5
PCIe: 4.0 x8
模型: Qwen3.6-35B-A3B NVFP4

NVFP4 把专家权重压缩到了接近 4bit,完整专家池主要放在 32GiB 系统内存中。显存里放的是:

  1. 注意力、路由器、归一化等非专家权重
  2. KV Cache 和运行时工作区
  3. 一部分最近使用的专家

所以这其实是一个三级存储问题:

1
2
3
4
5
磁盘:保存模型文件
↓ 启动时读取
内存:保存完整专家池,是权重的 source of truth
↓ 按需搬运
显存:保存非专家权重、KV Cache 和热点专家

显存不再是模型的完整仓库,而是最快的一层缓存。

FreeToken 的内存层次

这跟操作系统的虚拟内存、CPU Cache 有一点像:完整数据在容量更大的慢速介质里,当前最可能使用的数据留在小而快的介质里。

不过模型推理有一个特别麻烦的地方:下一步会用到哪个专家,是运行到路由器之后才知道的。所以光有“缓存”两个字还不够,还需要解决命中、换入、并发和调度的问题。

FreeToken 的基本结构

FreeToken 把一个 MoE 模型拆成两部分:

非专家权重常驻 GPU

注意力层、Gated DeltaNet、路由器、共享专家等不会像路由专家那样从几百个里面选几个。它们每次都会使用,所以尽量常驻 GPU。

完整路由专家池常驻内存

所有层的全部专家都保留在 Host RAM 中。这一份是完整权重,也是最终兜底:

  • GPU 缓存命中,直接在 GPU 上算
  • GPU 缓存未命中,可以把专家搬进显存
  • 如果不值得搬,也可以直接由 CPU 计算

因此 GPU 缓存大小只影响速度,不影响模型能不能算出正确结果。即使缓存是冷的,Host RAM 中仍然有完整专家。

剩余显存组成全局专家缓存

FreeToken 没有给每一层固定切一块显存,而是把专家缓存做成跨层共享的 slot pool。

一个 slot 保存的是某一层某一个专家需要的全部权重:

1
(layer_id, expert_id) -> gpu_slot

以 Qwen3.6-35B-A3B 为例:

1
2
40 层 × 每层 256 个专家
= 10240 个不同的 (layer, expert)

显存不需要有 10240 个 slot。假设当前只能放 600 个,就由所有层共同竞争这 600 个位置。最近使用过的专家留下,长时间没用的专家被淘汰。

这比“每层固定留 15 个专家”灵活,因为不同层、不同会话的热点分布并不平均。

生成阶段:专家缓存到底怎么工作

大模型推理通常分成两个阶段:

1
2
Prefill:一次处理完整输入,得到第一个输出 token
Decode:之后每轮只新增一个 token

这两个阶段虽然都经过 MoE 层,访问专家的模式却完全相反。先看 Decode。

假设某一层有 256 个专家,当前 token 被路由到了 8 个专家:

1
[17, 42, 58, 91, 103, 144, 201, 233]

FreeToken 会先查询每个专家是否在 GPU slot 中。

源码中的核心映射可以简化成:

1
2
3
4
5
6
7
8
# 正向表:某层某专家当前在哪个 GPU slot
slot_for_id[layer_id][expert_id] = slot_id

# 反向表:某个 slot 当前装的是谁
id_of_slot[slot_id] = layer_id * num_experts + expert_id

# LRU 时间戳
usage[slot_id] = current_step

一次查询之后会得到两组:

1
2
命中 H:已经在 GPU 中,可以立即计算
未命中 M:仍然只在内存中

命中的部分很好处理。真正有意思的是未命中。

传统想法通常有两个极端:

1
2
方案 A:未命中专家全部通过 PCIe 搬到 GPU
方案 B:未命中专家全部留在内存,由 CPU 计算

FreeToken 选择了第三种:

1
2
一部分搬到 GPU
另一部分同时交给 CPU

FreeToken 的 Decode 路径

具体执行顺序大致是:

1
2
3
4
5
6
7
8
9
1. 路由器给出逻辑 expert id
2. 在全局 LRU 缓存中查找 slot
3. 命中的专家直接改写为物理 slot id
4. 为需要搬运的 miss 选择 LRU victim
5. 先提交 CPU 专家计算
6. 同时通过 PCIe 把另一部分 miss 搬进 GPU slot
7. GPU 计算“原有 hit + 新填入的专家”
8. 等待 CPU 部分完成
9. 把 CPU 和 GPU 的部分结果相加

对应到源码,python/freetoken/layers/moe.py 中的 _decode_hybrid() 做的正是这件事。它先提交 CPU task,再调用 copy_missing() 和 GPU expert GEMM,最后:

1
return gpu_routed + cpu_routed

为什么可以直接相加?

一个标准 MoE 层的结果本来就是多个专家输出的加权和:

1
y = g1 × E1(x) + g2 × E2(x) + ... + gk × Ek(x)

把专家集合拆成 GPU 集合和 CPU 集合,只是改变了计算地点:

1
2
3
4
y_gpu = GPU 上专家的加权和
y_cpu = CPU 上专家的加权和

y = y_gpu + y_cpu

FreeToken 会保证一条路由只由一边计算一次。源码中,交给 CPU 的专家在 GPU 路径里权重会被置零,交给 GPU 的专家在 CPU 路径里会被标记成 -1 并跳过。

所以这里不是删掉几个专家,也不是近似计算。除了模型本身使用的量化精度,CPU/GPU 分流不会额外改变 MoE 的数学定义。

q*:为什么不是一半给 CPU、一半给 GPU

现在还剩一个问题:假设有 4 个专家没有命中,到底应该搬几个?

如果全部搬运,CPU 可能在旁边闲着。如果全部交给 CPU,PCIe 和 GPU 又会闲着。最合适的比例跟机器有关。

FreeToken 用两个实际测出来的带宽做决定:

1
2
3
4
B_P:内存通过 PCIe 向 GPU 搬专家的有效带宽
B_H:CPU 直接读取并计算专家时的有效内存带宽
m:这一层没有命中的专家数量
q:其中选择搬入 GPU 的数量

注意,PCIe DMA 和 CPU 计算都要从同一份内存中读专家权重。PCIe 正在以 B_P 读数据时,能留给 CPU 的大约是:

1
B_R = B_H - B_P

假设每个专家大小都是 S,两个并发分支的耗时近似为:

1
2
GPU 搬运分支:T_fill ≈ q × S / B_P
CPU 计算分支:T_cpu ≈ (m - q) × S / (B_H - B_P)

两个任务是并行的,总时间取较慢的那个。要让硬件利用得最充分,就尽量让它们同时结束:

1
q × S / B_P ≈ (m - q) × S / (B_H - B_P)

两边约掉 S,整理一下:

1
q* ≈ m × B_P / B_H

这就是论文中的 q* policy

这个推导默认 B_H > B_P,也就是 PCIe 占满以后还有一部分 Host 带宽能留给 CPU。实际执行时还会把 q 取整并限制在合法范围。对于 m > 0 的情况,可以把它理解成:

1
q = clamp(round(q*), 1, m)

B_H 接近或者低于 B_P 时,CPU 已经没有值得利用的剩余带宽,策略就退化成 q = m,把 miss 都送去 GPU;至少保留一次 fill,也能让冷缓存继续升温。

用论文里 4060 Laptop 的实测数据算一次:

1
2
3
4
5
6
B_P = 11.8 GB/s
B_H = 47.5 GB/s
m = 4

q* ≈ 4 × 11.8 / 47.5
≈ 0.99

取整之后就是:

1
2
1 个专家通过 PCIe 搬进 GPU
3 个专家直接由 CPU 计算

这里有一个挺反直觉的地方:CPU 并不是因为算力特别强才参与,而是因为 Decode 的 batch 通常很小,专家 GEMV 更容易受内存带宽限制。CPU 计算那 3 个专家,实际上是在利用 PCIe 没有吃掉的剩余内存带宽。

如果换到另一台机器:

  • PCIe 很快、CPU 内存带宽不高,q* 会接近 m,更适合多搬到 GPU
  • 内存带宽明显高于 PCIe,CPU 可以承接更多 miss
  • 专家本来就在缓存里,则根本不进入 q* 分流

所以 FreeToken 不靠显卡型号表拍脑袋。它提供:

1
ft bench bw

这个命令使用真实的专家搬运和 CPU MoE kernel 测量带宽,并把结果写到:

1
~/.cache/freetoken/benchbw.json

ft serve --moe-backend auto 会根据这份 profile 决定继续使用 offload,还是升级为 CPU/GPU 并行的 hybrid。

为什么 LRU 专家缓存会有效

如果每个 token 选中的专家完全随机,刚放进 GPU 的专家下一步就再也不用,那么缓存没有太大价值。

实际路由通常存在短期局部性。相邻 token 处在同一段语义、同一种语言或同一段代码中,路由到的专家经常重叠:

1
2
3
token t:     [17, 42, 58, 91, 103, 144, 201, 233]
token t + 1: [17, 42, 61, 91, 103, 144, 205, 233]
↑ ↑ ↑ ↑ ↑ ↑

第二个 token 中就有多个专家可以直接命中。

FreeToken 使用全局 LRU:

1
2
3
4
命中:更新 usage
未命中:找最久没有使用的 slot
换入:更新正向表和反向表
执行:把逻辑 expert id 改写为 slot id

源码里的实现还多做了一步:命中判断、miss 去重、q 的计算、victim 选择和 ID 改写都在 GPU kernel 中完成。

原因是 CUDA Graph 喜欢固定的控制流和固定形状。如果每一层都把 expert id 拷回 CPU,让 Python 决定淘汰谁,再通知 GPU,光同步就会非常昂贵。

FreeToken 的做法是:

1
2
控制流保持固定
变化的命中数、miss 数和 victim 都作为 GPU 上的数据

工作缓冲区大小固定,再用一个有效数量屏蔽没有使用的部分。这样动态 LRU 仍然可以被放进静态捕获的 CUDA Graph。

论文给出的路由回放结果也能看到这个差别。在相同缓存容量下,Qwen3.6 的 Decode miss rate:

1
2
3
FreeToken 全局 LRU:16%
KTransformers 预填充时更新的静态放置:41%
llama.cpp 路由无关的静态切分:62%

这个数字不是说 LRU 永远都会命中 84%,而是说明在论文的四类 agent workload 和指定缓存容量下,token 之间确实存在可以利用的路由局部性。

Prefill 为什么不能也按需加载

看完 Decode 很容易产生一个想法:Prefill 也让选中的专家按需进入缓存不就行了?

问题在于 Prefill 不是一次只处理一个 token,而是一次处理几千个 token。

对单个 token 来说:

1
256 个专家中只选 8 个

但是一批 8192 个 token 各选 8 个,取所有路由结果的并集后,几乎每个专家都会被碰到:

1
2
3
4
5
6
token 1    -> 8 个专家
token 2 -> 另外一些专家
...
token 8192 -> 另外一些专家

所有 token 的并集 -> 接近完整 256 个专家

也就是说,MoE 在 Decode 阶段是稀疏工作集,到了长 Prompt 的 Prefill 阶段却接近稠密工作集。

如果还逐个 miss、逐个搬运,会产生很多细碎传输,GPU 也会不断等 PCIe。

FreeToken 因此给 Prefill 设计了另一条路径:完整层双缓冲。

FreeToken 的 Prefill 双缓冲

它从全局专家 slot pool 中借出能容纳两层完整专家的空间:

1
2
Buffer A:GPU 正在计算第 l 层
Buffer B:PCIe 同时传输第 l + 1 层

l 层算完后,两块 buffer 交换角色:

1
2
Buffer B:计算第 l + 1 层
Buffer A:传输第 l + 2 层

因为搬的是下一层完整专家,甚至不需要等下一层路由结果出来就可以提前开始传输。理想情况下,一层的计算时间被下一层的搬运时间完全覆盖。

这也解释了为什么至少要有两层专家大小的缓存才能开启 overlap。源码中 OffloadMoeCache 会直接检查:

1
cache_size >= 2 × num_experts

空间不足时不会硬撑,而是回退到按需 Prefill。

论文在 RTX 5090 上关闭双缓冲后,4K、8K、16K Prompt 的 Prefill 吞吐分别下降了 19%、25% 和 26%。Prompt 越长,完整专家池的搬运越无法忽略。

Agent 场景里还有一种重复:上下文被改写

普通聊天通常只是在历史后面追加内容,前缀 KV Cache 很容易复用。

Coding Agent 不一样。一次工具调用之后,框架可能会:

  • 删除旧的 thinking
  • 用占位符替换太老的工具输出
  • 截断历史 observation
  • 保留前半段,再改写中间某一块

Qwen3.6 这类混合架构不仅有完整注意力,还有 Gated DeltaNet 这样的递归状态。KV Cache 可以按 token 保存,递归层却把前缀压缩成一个不断演进的 state。

如果中间一块上下文被改了,修改点后面的 state 都失效。检查点又很少的话,只能从很早的位置重新 Prefill。

FreeToken 的做法是把有限的 recurrent-state checkpoint 放在更可能存活的语义边界:

1
2
3
4
一轮对话结束
thinking 结束
tool call 开始/结束
tool output 开始/结束

Agent 框架通常也是按这些完整区块删除内容,所以边界之前的前缀更可能保持不变。恢复时:

1
2
3
找到仍然有效的最深语义锚点
恢复对应的 KV / recurrent state
只重新计算变化后的后缀

这部分跟专家缓存解决的不是同一个问题:

缓存缓存的东西主要减少什么
专家缓存MoE 权重Decode 时的 PCIe 搬运和 CPU 计算
KV / 状态缓存Prompt 的计算结果多轮 Agent 中重复 Prefill

FreeToken 把它们放进同一个 serving engine,是因为本地 Agent 的总等待时间同时受两边影响。只有 Decode 跑得快,但每次工具调用后重新 Prefill 一分钟,体验仍然不行。

显存还要在专家缓存和 KV Cache 之间动态分配

刚启动模型时上下文很短,可以把更多剩余显存用作专家缓存。对话越来越长后,KV Cache 会持续增长,而专家工作集没有同比增长。

静态切分很容易出现:

1
2
3
专家缓存很大,但 KV Cache 不够
或者
KV 预留太多,专家缓存命中率很低

FreeToken 把这两块都看成运行时资源。在 scheduler 没有待处理 Prefill、没有进行中的 Decode 时,到达一个 safe point,才执行 cache rebuild:

1
2
3
4
5
暂停接收新的计算批次
释放旧 CUDA Graph
重新分配专家 slot 和 KV page
重新捕获 CUDA Graph
继续服务

Host RAM 中的完整专家池不需要重新加载,所以不必重启进程或者再次读取整个模型。

可以通过:

1
2
ft ctl cache
ft ctl cache --moe 512 --kv 16k

查看和调整缓存。调整不是发生在某个 token 计算到一半的时候,而是排队等到 scheduler 完全空闲的安全点。

FTW 为什么能缩短启动时间

模型文件里的专家权重未必正好是运行时最方便的排列。FreeToken 会把不同模型的专家整理成少数几种 expert bank,并把:

1
flat_id = layer_id × num_experts + expert_id

作为每个 bank 的第一维。

同一个 flat_idgate_updown、scale 等 bank 中对应同一个完整专家。这样 GPU slot cache 和 CPU executor 共用一套逻辑 ID,不需要理解每种模型原始 checkpoint 的布局。

FTW 是 FreeToken 的 fast weight format。它不是新的量化算法,主要作用是预先把权重转换成运行时最终布局。

传统加载过程可能是:

1
2
3
4
5
申请巨大空缓冲区
把内存页 pin 住并清零
读取 checkpoint
重新排列专家
复制到最终位置

FreeToken 的路径是:

1
2
从磁盘直接读入最终 Host 布局
数据填充完成后再 pin

Pinned memory,也就是 page-locked memory,不会被操作系统随便换出,CUDA 可以对它进行更稳定的异步 DMA。但提前 pin 一个巨大的空缓冲区会触发页面分配和清零,随后又马上被模型数据覆盖,白做了一遍内存写入。

FTW 避免了这部分启动开销。GPU 专家缓存也不需要专门 warmup,第一次请求从冷缓存开始,沿用普通 miss 路径自然升温。

ft checkpoint 是可选步骤,FreeToken 也能直接读取支持的 Hugging Face checkpoint:

1
2
3
4
ft checkpoint \
--model ~/models/Qwen3.6-35B-A3B \
--out ~/models/Qwen3.6-35B-A3B-FTW \
--moe-backend offload

从源码看这些模块怎么对应

把论文和代码对照起来,大致是下面这些位置:

源码主要职责
python/freetoken/moe/offload_cache.py全局专家 slot、LRU 映射、专家 bank、Prefill 双缓冲
python/freetoken/moe/offload_kernels.pyGPU 上做 hit/miss 分类、q 计算、victim 选择和 ID 改写
python/freetoken/layers/moe.py串起路由、CPU 提交、PCIe copy、GPU GEMM 和结果合并
python/freetoken/moe/cpu_executor.py固定 CPU worker、SIMD 和量化专家计算
python/freetoken/moe/benchbw.py测量真实 CPU MoE 与 PCIe 搬运带宽
python/freetoken/engine/cache_budget.py在专家 slot 和 KV page 之间计算显存预算
python/freetoken/scheduler/scheduler.py在 idle safe point 执行运行时 cache rebuild

其中最核心的 Decode 伪代码其实不复杂:

1
2
3
4
5
6
7
8
9
10
11
12
routes = router(hidden_states)

hits, misses = gpu_cache.lookup(routes)
to_gpu, to_cpu = split_by_bandwidth(misses)

cpu_task = cpu_executor.submit(to_cpu)
gpu_cache.fill(to_gpu)

gpu_output = gpu_experts(hits + to_gpu)
cpu_output = cpu_task.wait()

return gpu_output + cpu_output

真正难的是让 lookup、动态 miss 数、异步拷贝、CPU worker 和 CUDA Graph 同时成立,而且不能每层都把控制权交回 Python。

跟 llama.cpp 的部分卸载有什么区别

两者最表面的相同点都是:

1
显存不够,就让一部分权重留在内存

但“哪一部分留在内存”和“运行时怎么使用它”差别很大。

llama.cpp 与 FreeToken 的卸载方式

llama.cpp 的 -ngl 是静态层卸载

以一个 64 层 Dense 模型为例:

1
2
第 0~39 层:CPU
第 40~63 层:GPU

模型加载完成后,层的位置基本就固定了。每生成一个 token,仍然要依次经过全部 64 层:

1
2
3
CPU 计算前 40 层
传递中间激活
GPU 计算后 24 层

这里通常移动的是层与层之间较小的激活,不是每个 token 都把完整层权重搬来搬去。

这种方式非常适合 Dense 模型,因为 Dense 没有路由器,任何一层的 FFN 权重每个 token 都会使用。也就谈不上“缓存最近用到的专家”。

当前 llama.cpp 的参数是:

1
2
3
-ngl N
--gpu-layers N
--n-gpu-layers N

现在也可以使用 autoall,让运行时自动拟合显存。

llama.cpp 也有专门的 MoE CPU 放置

如果模型是 MoE,当前 llama.cpp 还支持:

1
2
-cmoe
--cpu-moe

把全部 MoE 专家权重留在 CPU;或者:

1
2
-ncmoe N
--n-cpu-moe N

只把前 N 个 MoE 层的专家留在 CPU。

这已经比单纯的 -ngl 更接近 FreeToken:注意力等非专家部分可以继续在 GPU,而巨大的专家权重留在内存。

llama.cpp 当前的 scheduler 还能根据路由 ID,只处理被选中的 expert sub-row,而不是无条件复制所有专家。不过截至 2026 年 8 月 23 日,官方 master 的这条路径没有 FreeToken 那样跨 token 持久存在的 expert -> slot 映射。被选中的专家可以在一次 forward 中搬到 GPU tensor 的原始偏移位置,但下一次相同专家仍可能再次搬运。

llama.cpp 社区已经有 persistent expert cache 的 RFC 和 PR,例如 #20757#26824,说明这个方向也正在快速演进。这里对比的是本文日期的官方 CLI 和 master 行为,不应该理解成 llama.cpp 永远不会有专家缓存。

FreeToken 优化的是动态专家工作集

FreeToken 的主要单位不是“第几层放在哪”,而是:

1
当前这个 (layer, expert) 应该在哪里执行
对比项llama.cpp -nglllama.cpp --cpu-moeFreeToken hybrid
主要对象完整层MoE 专家 tensor单个 (layer, expert)
放置时机加载时静态决定加载时决定专家在 CPU每个 Decode step 动态决定
GPU 专家驻留不适用官方 master 无跨 step slot cache全局 LRU 持久缓存
Cache miss不适用CPU 计算或按路由搬运一部分搬 GPU,一部分 CPU 计算
CPU/GPU 比例由层数决定由 CPU MoE 层数等配置决定q* 根据实测带宽决定
PrefillCPU/GPU 按层执行后端相关的专家处理完整层双缓冲隐藏 PCIe
Agent 状态复用KV/Context checkpointKV/Context checkpointKV + 语义 recurrent-state anchor
格式和平台GGUF,CPU/CUDA/Metal/Vulkan 等同左当前开源 CLI 主要是 Linux x86_64 + NVIDIA CUDA

所以它们不是简单的“一个能卸载,一个不能卸载”。

llama.cpp 是通用、本地部署范围很广的推理引擎,Dense 模型、Apple Silicon 和各种后端都是它的优势。FreeToken 则把目标收得更窄,专门围绕“显存装不下完整 MoE 专家池”设计整个 serving stack。

那么 Qwen3.8-27B 在 4060 上应该用哪一种

回到最开始容易串在一起的问题。

Qwen3.8-27B 是 Dense 模型:

1
2
没有 256 选 8 的路由专家
每个 token 都要经过每层完整 FFN

27B 做 4bit 的理论权重大小约为:

1
27B × 0.5 byte = 13.5GB

再加量化元数据、KV Cache 和运行时开销,8GB 4060 无法让它全量常驻显存。

这时更合适的思路是:

1
2
3
4
GGUF 低比特量化
+ llama.cpp
+ 尽可能多的静态 GPU layer offload
+ 剩余层在 CPU / RAM 中执行

例如:

1
2
3
4
llama-server \
-m Qwen3.8-27B-Q3_K_M.gguf \
-ngl auto \
-c 8192

具体能放多少层取决于 GGUF 大小、上下文长度、KV 精度、系统当前可用显存和 llama.cpp 版本。-ngl auto 比照搬别人机器上的固定层数更稳妥。

FreeToken 当前文档虽然支持 Qwen3.6-27B Dense,但 Dense 模型的 auto 会解析为 fused,也就是要求权重常驻 GPU,并不会启用 MoE offload/hybrid 这套专家机制。当前支持列表也还没有列出刚发布的 Qwen3.8-27B。

因此:

1
2
3
4
5
8GB 4060 + Qwen3.6-35B-A3B MoE
-> FreeToken 的专家缓存和 hybrid 很有意义

8GB 4060 + Qwen3.8-27B Dense
-> 更适合 llama.cpp 的量化和静态层卸载

两个模型参数接近,但运行路径不能混着解释。

实际怎么跑 FreeToken

当前开源 CLI 的要求是:

1
2
3
4
5
Linux x86_64
NVIDIA GPU
driver r580+
CUDA 13
Python 3.10+

所以它目前不是 M3 Pro 上 llama.cpp/Metal 的替代品。Mac 仍然更适合 llama.cpp 或 MLX。

安装:

1
2
3
uv venv
source .venv/bin/activate
uv pip install "freetoken[accel]"

先测一次这台机器的 CPU/PCIe 带宽:

1
ft bench bw --dtype nvfp4

然后启动论文中 8GB 机器使用的 NVFP4 模型:

1
ft serve --model nvidia/Qwen3.6-35B-A3B-NVFP4

只提供 --model 也可以,dtype、attention backend、MoE backend、专家缓存和 KV 容量会根据 checkpoint、显存和带宽 profile 自动推导。

想明确观察模式,可以分别测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 专家在内存,miss 全部搬到 GPU
ft serve \
--model nvidia/Qwen3.6-35B-A3B-NVFP4 \
--moe-backend offload

# 专家在内存,miss 由 CPU 处理
ft serve \
--model nvidia/Qwen3.6-35B-A3B-NVFP4 \
--moe-backend cpu

# GPU cache hit + PCIe fill + CPU overflow 并行
ft serve \
--model nvidia/Qwen3.6-35B-A3B-NVFP4 \
--moe-backend hybrid

运行后可以查看:

1
2
ft ctl stats
ft ctl cache

对比时不要只测一句 hello。至少分别看:

  • 冷启动后的第一轮
  • 长 Prompt 的 TTFT
  • 连续生成几百 token 后的 Decode
  • 多轮工具调用后是否反复 Prefill

短 Prompt、冷缓存和长时间 Agent 会话测到的是三种不同状态。

这套方案的限制

看到 39.3 tok/s 很容易只记住“8GB 显存”,但这套方案还有几个前提。

内存仍然要装得下完整专家池

显存需求降低不等于总内存需求消失。论文里的 4060 Laptop 有 32GiB 内存,并且使用 NVFP4。只有 16GB 系统内存时,同一模型可能会进入 swap,速度会急剧下降,甚至无法稳定加载。

主要适用于 MoE

Dense 模型每个 token 都要读完整 FFN,没有小而稳定的专家工作集。FreeToken 的核心专家缓存不会把 27B Dense 变成 3B active。

性能受整机而不是只受 GPU 决定

需要同时看:

  • Host RAM 带宽
  • PCIe 代际和通道数
  • CPU SIMD 能力
  • 物理核心数
  • 显存能留下多少专家 slot
  • 实际路由的局部性

同一张 4060 放在不同笔记本里,内存和 PCIe 配置不同,q* 和速度也会不同。

39.3 tok/s 不是所有请求的固定速度

这是论文在指定 4060 Laptop、NVFP4 模型和 coding-agent workload 下测得的 Decode 吞吐。它不等于:

  • 冷启动速度
  • Prefill 速度
  • 超长上下文下的平均响应速度
  • 任意量化、任意机器都能得到的速度

缓存也有冷启动和工作集突变

刚启动时专家缓存是空的。如果话题、语言或者任务突然变化,路由热点也可能变化,短时间 miss 会增加。LRU 利用的是局部性,不是消灭所有 miss。

最后总结

现在再看“8GB 显存运行 35B”,可以把它拆成五步:

1
2
3
4
5
1. Qwen3.6-35B-A3B 是 MoE,每个 token 只激活约 3B
2. 完整的量化专家池放在 32GiB Host RAM,不是硬塞进 8GB VRAM
3. 剩余显存作为跨层共享的 LRU 专家 slot cache
4. Decode miss 按 q* 分给 PCIe+GPU 和 CPU,两条路径并发并精确合并
5. Prefill 改用完整层双缓冲,并通过语义状态缓存减少 Agent 重算

llama.cpp 的 -ngl 主要解决的是“完整层怎样静态分布到 CPU 和 GPU”,--cpu-moe 则进一步允许专家留在 CPU。FreeToken 在这个基础问题上继续往前走,把专家当成随 token 路由变化的动态工作集,并围绕它设计缓存、带宽分流、Prefill 和运行时显存管理。

所以 FreeToken 真正有意思的地方并不是一个神奇的“8GB 跑 35B”数字,而是它换了一个看待本地机器的方式:

1
2
3
GPU 不是唯一计算设备
显存也不是唯一模型内存
CPU、RAM、PCIe 和 GPU 可以组成一台统一的推理机器

但这条路成立的关键仍然是 MoE 的稀疏性。换成 Qwen3.8-27B 这样的 Dense 模型,还是要老老实实回到量化、层卸载和内存带宽这几个问题上。

参考资料

最近deepseek发布了harness,试用了一下结合deepseek-4-flash,用它尝试写一个php的现代版phpmyadmin,因为也是一个比较重要且有一点复杂度的应用
试了下除了数据展示有点问题,修了一轮就完成了,整体表现还是不错的
然后看了下它主打的是万物都是插件
这个概念还是比较复杂的,尝试让gpt老师帮我讲解下
DeepSeek Harness 就像一个“由很多小员工临时组成的公司”。
没有一个超级总经理包办所有事情:

  • 一个员工负责问大模型;
  • 一个员工负责读文件;
  • 一个员工负责运行命令;
  • 一个员工负责审批危险操作;
  • 一个员工负责保存聊天记录;
  • 一个员工负责显示网页界面。
    这些“员工”就是插件。
    需要什么员工,就把什么员工叫来;不需要了,就让它离开,并自动收拾自己留下的东西。

用一个真实请求走一遍

你对 DeepSeek Harness 说:

帮我读取 package.json,看看项目用了哪些依赖。
系统内部大致发生了这些事:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
你输入问题

Agent Loop 插件:决定下一步做什么

LLM 插件:把问题发给 DeepSeek

DeepSeek 返回:我要调用 read_file

权限插件:这个文件允许读吗?

文件工具插件:读取 package.json

日志插件:记录本次工具调用

Agent Loop 插件:把文件内容再次交给 DeepSeek

DeepSeek 生成最终回答

Web UI 插件:把回答显示出来

关键点是:
上面每一个步骤都可以由不同插件负责。
比如你可以:

  • 把 DeepSeek 换成其他模型;
  • 把本地文件系统换成远程沙箱;
  • 给文件读取前增加权限检查;
  • 给模型调用增加重试;
  • 给工具调用增加日志;
  • 换掉整个 Agent Loop。
    其他部分不需要跟着重写。

为什么普通插件系统做不到这么灵活

普通软件的结构经常是:

1
2
3
4
固定核心程序
├── 插件位置 A
├── 插件位置 B
└── 插件位置 C

核心程序是老板,插件只能填几个预留位置。
DeepSeek Harness 更像:

1
2
3
4
5
6
7
极小的组装平台
├── Agent Loop 插件
├── 模型插件
├── 工具插件
├── 权限插件
├── 存储插件
└── UI 插件

连“核心 Agent 怎么循环运行”本身也是插件。
所以它不是:

一个 Agent,加上一些插件。
而是:
很多插件组合起来以后,才形成一个 Agent。

插件之间怎么找到对方

假设你写了一个天气工具插件。
它不能一启动就直接工作,因为它要先找到“工具管理器”。
所以它会声明:

1
我需要:tools 服务

代码里大概是:

1
export const inject = ['tools']

意思就是:

只有工具管理器存在时,才启动我。
系统启动时可能出现这种情况:

1
2
3
4
5
6
天气插件:我需要 tools
工具管理器:还没启动
天气插件:那我先等着

工具管理器启动
天气插件:条件满足,现在开始工作

如果工具管理器后来被替换:

1
2
3
4
5
6
7
旧工具管理器卸载

天气插件也暂时卸载

新工具管理器启动

天气插件重新启动,并连接新管理器

这就是 DeepSeek Harness 很特别的地方:

插件依赖不是只在启动时检查一次,而是一直被系统关注。

ctx 到底是什么

插件启动时会拿到一个 ctx

1
2
3
export function apply(ctx) {
// 使用 ctx 做事
}

你可以把 ctx 理解成“公司总机”。
插件不知道其他插件住在哪里,只需要问总机:

1
2
3
4
5
ctx.tools       // 工具管理器
ctx.llm // 模型管理器
ctx.sessions // 会话管理器
ctx.fs // 文件系统
ctx.sandbox // 沙箱

比如天气插件说:

1
ctx.tools.register(weatherTool)

意思就是:

总机,请把我的天气能力登记到工具管理器里。
这样天气插件不需要自己找到 Agent Loop,也不需要直接修改模型提示词。工具管理器会负责把天气工具告诉模型。

插件卸载时,怎么避免留下垃圾

这是整个系统最值得理解的设计。
假设天气插件启动后做了三件事:

1
2
3
1. 注册 weather 工具
2. 监听工具调用事件
3. 启动一个定时器

如果只是粗暴地删除插件代码,可能留下:

  • 一个无法调用的 weather 工具;
  • 一个永远存在的事件监听器;
  • 一个一直运行的定时器。
    DeepSeek Harness 要求插件在创建东西时,同时登记“怎么撤销”。
    可以理解成:
    1
    2
    3
    4
    5
    6
    7
    8
    创建 weather 工具
    同时记下:卸载时删除 weather 工具

    添加事件监听器
    同时记下:卸载时移除监听器

    启动定时器
    同时记下:卸载时停止定时器
    于是卸载插件时,系统会自动倒着清理:
    1
    2
    3
    停止定时器
    移除监听器
    删除 weather 工具
    所以热更新才比较可靠。
    修改天气插件代码后,系统可以:
    1
    2
    3
    4
    5
    完整清理旧天气插件

    重新加载新代码

    重新注册新天气工具
    而不是让新旧两套东西同时残留。
    这个“创建时顺便记录撤销方法”的机制,技术上叫 effect

Fiber 又是什么

现在再引入 Fiber 就很容易了。
Fiber 可以理解成:

某个插件这一次运行的“工作档案”。
里面记录着:

  • 这个插件是谁;
  • 它在等哪些服务;
  • 它现在有没有启动;
  • 它注册过什么东西;
  • 卸载时要清理什么;
  • 它是否启动失败。
    一个插件大致会经历
    1
    2
    3
    4
    5
    6
    7
    8
    9
    等待条件

    正在启动

    正常运行

    正在清理

    已经卸载
    对应的技术名称是:
    1
    2
    3
    4
    5
    PENDING
    → LOADING
    → ACTIVE
    → UNLOADING
    → DISPOSED
    所以 Fiber 不是线程,也不是子进程。
    它只是插件的一份“生命周期档案”。

插件怎么插手系统流程

有些插件提供能力,比如文件系统。
另一些插件不提供新能力,只想在某个流程中插一脚。
例如权限插件想在执行 Bash 前检查命令:

1
2
3
4
5
6
模型请求执行 rm ...

权限插件检查

允许:继续执行
拒绝:直接返回错误

这个过程很像高速公路收费站:

1
2
3
4
5
6
请求
→ 审批站
→ 权限站
→ 超时站
→ 日志站
→ 真正执行

每一站都可以:

  • 检查请求;
  • 修改请求;
  • 记录请求;
  • 放行;
  • 拒绝继续。
    在代码里,放行通常是调用:
    1
    next()
    不调用 next(),就表示到此为止。
    这就是它所谓的 waterfall。本质上就是一条可插拔的中间件链。

Bundle 和 Profile 又是什么

这里最容易被名词绕晕。

Plugin 是员工

真正执行工作的代码。

Bundle 是部门安装包

一个 Bundle 可能一次带来很多插件,例如:

1
2
3
4
5
6
Web Bundle
├── Web 服务器插件
├── 浏览器通信插件
├── 聊天界面插件
├── 设置页面插件
└── Web 启动插件

Profile 是公司组织方案
比如:

1
2
3
web Profile
├── 基础 Bundle
└── Web Bundle

而:

1
2
3
headless Profile
├── 基础 Bundle
└── 命令行 Bundle

因此:

1
dsh --profile web

可以理解成:

按照 Web 这套组织方案,把对应部门和员工全部组装起来。

安装一个插件时发生什么

执行:

1
dsh plugin --profile web add some-plugin

大致等于:

1
2
3
4
5
1. 用 pnpm 下载 npm 包
2. 查看这个包有没有声明自己是 DSH Bundle
3. 找到它携带的插件配置
4. 把它加入 web Profile
5. 下次启动时加载其中的插件

大概是这么个逻辑

之前也偶尔用户pi agent这个工具,当然这个还是个很庞大的系统,我也只能一点点学
首先是工具系统,默认的核心工具主要是四个 read,write,edit,bash

read 主要是读取文件

输入结构式这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"path": "src/app.ts",
"offset": 1,
"limit": 200
}
- path:相对或绝对路径
- offset:从第几行开始,1 开始计数
- limit:最多读取多少行
内部的原理大致是
```text
解析路径
→ 检查文件是否可读
→ 判断是文本还是图片
→ 读取内容
→ 按 offset/limit 截取
→ 限制输出大小
→ 返回给模型

对于文本,Pi 将文件按照 UTF-8 解码并拆分为行。默认最多返回前 2000 行或 50KB,哪个先达到就按哪个截断,并告诉模型下一次应该使用哪个 offset 继续读取。

1
[Showing lines 1-2000 of 3540. Use offset=2001 to continue.]

write 创建文件或完整覆盖

输入

1
2
3
4
{
"path": "src/Hello.php",
"content": "<?php\n\necho \"hello\";\n"
}

主要原理

1
2
3
4
5
解析目标路径
→ 创建不存在的父目录
→ 进入该文件的 mutation queue
→ 完整写入 content
→ 返回写入结果

如果文件已经存在,则完整覆盖。它不是“追加”,也不是“局部修改”。
Pi 会针对同一个文件建立 mutation queue,防止多个并行工具同时修改同一个文件导致内容互相覆盖:

1
2
3
4
5
write A ──────┐
├→ 同一个文件顺序执行
edit A ──────┘

write B ─────────→ 不同文件可以独立处理

edit 精确替换已有代码

这是四个工具中设计最讲究的一个。
输入
当前 Pi 支持一次提交多个互不重叠的修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"path": "src/UserService.php",
"edits": [
{
"oldText": "public function find($id)",
"newText": "public function find(int $id): ?User"
},
{
"oldText": "return $this->users[$id];",
"newText": "return $this->users[$id] ?? null;"
}
]
}

内部原理

1
2
3
4
5
6
7
8
9
读取原文件
→ 去掉 BOM
→ 将 CRLF/CR 统一成 LF
→ 在原始文件中寻找每个 oldText
→ 检查每段是否唯一、是否重叠
→ 执行全部替换
→ 恢复原来的换行符和 BOM
→ 写回文件
→ 生成 diff 和 unified patch

关键要求是:oldText 必须精确匹配,而且在原文件里唯一。
例如文件中有两个:

1
return null;

模型如果只提交:

1
2
3
4
{
"oldText": "return null;",
"newText": "throw new RuntimeException();"
}

工具会拒绝,因为不知道应该修改哪一个。
模型必须提供足够的上下文:

1
2
3
4
{
"oldText": "public function load(): ?User\n{\n return null;\n}",
"newText": "public function load(): ?User\n{\n return $this->repository->first();\n}"
}

为什么采用精确替换

  • 它相当于一种轻量级“乐观锁”:
  • 模型看到文件版本 A
  • 模型根据 A 生成 oldText
  • 如果调用工具前文件被改成版本 B
  • oldText 匹配失败
  • 工具拒绝覆盖,要求模型重新读取
    这比让模型直接按行号修改更安全,因为文件新增一行后,行号可能全部偏移。
    执行成功后,Pi 同时生成:
  • 面向终端显示的彩色 diff
  • 标准 unified patch
  • 第一处变化的行号

bash 给模型提供“手脚”

输入

1
2
3
4
{
"command": "php -l src/UserService.php && php tests/run.php",
"timeout": 30
}

原理:

1
2
3
4
5
6
7
找到当前平台的 Shell
→ 在工作目录启动子进程
→ 注入环境变量
→ 同时监听 stdout/stderr
→ 实时把输出推送到 TUI
→ 等待退出码
→ 将结果返回给模型

它可以完成:

  • rgfindls 搜索项目
  • 运行测试
  • 调用编译器
  • 执行 Git 命令
  • 安装依赖
  • 启动构建脚本
  • 执行任意系统程序
  • 输出处理
    read 保留“开头”不同,bash 更适合保留“结尾”。
    因为测试、编译和日志输出的错误通常在末尾。因此 Pi 保留最后 2000 行或最后 50KB。如果发生截断,完整输出会另外保存到临时文件。

四个工具如何协作

假设用户说:
给项目增加一个 /health 接口并运行测试。

模型可能按下面的流程工作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
1. bash
rg "Route|router" src

2. read
读取找到的路由文件

3. edit
精确插入 /health 路由

4. write
创建新的 HealthController.php

5. bash
php -l ... && php tests/run.php

6. read
如果测试失败,读取相关源文件

7. edit
修复问题

8. bash
再次运行测试

这就是 Coding Agent 的核心闭环:

1
2
3
4
5
观察(read/bash)
→ 决策(LLM)
→ 修改(edit/write)
→ 验证(bash)
→ 再观察

大致学一下这个工具的工具逻辑

之前就比较好奇,命令行一般输出都是往后追加,好像不知道怎么做覆盖更新,但是很多比较厉害的shell脚本都有那种进度条,甚至整个重绘
所以就来看下,怎么在命令行里输出个会变的进度条,
比如我在命令行中输出两行

1
2
3
echo "111\n";
echo "222\n";
exit;

最简单就是这样子,那如果想把111改成222呢
我们发现”\n”是换行,那”\r”其实是原地移动到行首
可以再看下

1
2
3
4
echo "111\r";
usleep(1000*1000);
echo "222\n";
exit;

加了sleep是因为闪太快了看不到,这样就能看到这一行就重新刷新了
然后就可以看下进度条可以怎么输出了

1
2
3
4
5
6
for ($i = 0; $i <= 50; $i++) {
printf("progress: [%-50s] %d%%\r", str_repeat('#',$i), $i * 2);
usleep(1000 * 100);
}
echo "\n";
echo "Done.\n";

首先是解释下这段的含义

1
2
3
4
5
"progress: [%-50s] %d%%\r"
└──┬──┘ │ │ └─ 回到当前行开头
│ │ └──── 输出一个真正的 %
│ └────── 输出整数
└──────────── 输出宽度为 50 的字符串

感觉又变成了一个视觉游戏,以为是逐渐的在往后画
其实是一直在刷新这个进度条
首先是定义一个长度是50个字符的输出,用空格补全,然后会输出对应百分比,比如2%
就对应的是一个井号,然后用”\r”回到行首重绘
实际的就是第一次画一个#号,第二次画两个#号
这里最大的重点就是要让部分字符看着在变,部分看着不变,才能形成这种视觉假象
比如这里的50个字符长度,还有对应的”%”符号
否则就会变成一直在跳的了
对应的动的部分就是#号的增加,对应百分比数值也在往上涨
不过这里有点小问题,不知道有没有发现,它的数字没有定义位数
那么在达到100%的时候其实是有一点跳跃的

那如果想要改多行怎么办呢
就需要用ANSI 控制序列让光标向上移动若干行

1
2
3
任务 A:[########          ] 40%
任务 B:[############ ] 60%
总进度:[########## ] 50%

光标此时位于这三行下面。下一次刷新前,先让光标向上移动三行:

1
ESC [ 3 A

在php中可以写成

1
echo "\033[3A";

最简单的就是

1
2
3
4
5
6
echo "111\n";
echo "222\n";
echo "333\n";
echo "\033[3A";
usleep(1000*1000);
echo "444\n";

这样就会把前面的111,222,333给干掉,重新输出444
来个像样点的脚本就是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<?php

function progressBar(int $percent, int $width = 30): string
{
$percent = max(0, min(100, $percent));
$filled = (int) floor($percent * $width / 100);

return sprintf(
"[%-{$width}s] %3d%%",
str_repeat('#', $filled),
$percent
);
}

$lineCount = 3;

for ($i = 0; $i <= 100; $i++) {
$taskA = $i;
$taskB = min(100, $i * 2);
$total = (int) (($taskA + $taskB) / 2);

// 第一次输出前不需要移动光标
if ($i > 0) {
printf("\033[%dA", $lineCount);
}

$lines = [
"任务 A:" . progressBar($taskA),
"任务 B:" . progressBar($taskB),
"总进度:" . progressBar($total),
];

foreach ($lines as $line) {
// 回到行首并清除当前整行
echo "\r\033[2K";
echo $line;
echo PHP_EOL;
}

fflush(STDOUT);
usleep(100000);
}

这里的符号代表的含义

控制序列作用
\r回到当前行行首
\033[2K清除当前整行
\033[3A光标向上移动三行
\033[1A光标向上移动一行
\033[1B光标向下移动一行
\033 表示 ESC 字符,所以:
1
echo "\033[3A";

发送给终端的实际含义是:

1
ESC + [3A

也就是“光标向上移动三行”。
还是比较神奇的东西
以前一直没搞明白,刚好这次想到了
现在很多想claude code,包括opencode,pi等都是搞成了命令行的
这个大概就是这些命令行工具的基础
像opencode是使用的

1
2
3
@opentui/core
@opentui/solid
solid-js

pi则是使用

1
2
@earendil-works/pi-tui
其他的框架还有
语言开源库定位
TypeScriptOpenTUIOpenCode 使用的完整 TUI
TypeScriptpi-tuiPi 使用的轻量差量渲染
JavaScriptInk使用 React 编写 CLI
JavaScriptcli-progress单个或多个进度条
GoBubble TeaElm 风格 TUI 框架
RustRatatuiRust 生态常用 TUI
PHPSymfony Console进度条、表格和多区域刷新
PHP 里最省事的是 Symfony Console 的 section()
1
2
3
4
5
6
7
8
9
10
11
12
use Symfony\Component\Console\Output\ConsoleOutput;

$output = new ConsoleOutput();
$section = $output->section();

$section->writeln("任务 A:10%\n任务 B:20%\n任务 C:30%");

sleep(1);

$section->overwrite(
"任务 A:40%\n任务 B:50%\n任务 C:60%"
);
0%