Bun 用 Claude Code 以 Rust 重写:工程可控性科学客观分析及必然面临的实际问题
1. Bun 重写这件事真正难的不是写 RustBun 用 Claude Code 辅助把部分核心模块从 Zig 迁到 Rust这个动作在社区里被讨论得很多。但如果你只盯着「AI 写代码」这个点很容易忽略真正的工程问题一个已经跑在生产环境里的 JavaScript 运行时怎么在不停机、不破坏 ABI、不拖慢启动速度的前提下把底层语言换掉。这跟从零写一个新项目完全是两码事。我自己在系统层做过类似的渐进式替换踩过的坑基本集中在三个地方FFI 边界的类型与内存布局、异步运行时的调度权归属、以及构建产物在 CI 里的可复现性。Bun 的场景更极端因为它同时背着 JavaScriptCore 的 C 接口、libuv 的事件循环、还有 Zig 时代留下的comptime宏展开逻辑。Claude Code 在这里能加速的是「把一段已知语义的 Zig 翻译成符合 Rust 所有权规则的等价实现」但它没法替你决定边界画在哪。这篇文章不聊「Rust 比 Zig 好在哪」这种口水话题而是给出一套可跟做的迁移验证流程从统一模型通道配置、到 FFI 骨架、到异步桥接、再到基准回归。适合正在做跨语言重写、或者准备用 Claude Code 辅助系统层编码的工程师。下面所有配置和命令都可以直接复制。2. 前置用 TaoToken 统一 Claude Code 的模型通道Claude Code 在重写任务里承担的是「批量生成 边界审查」的角色调用频率高、上下文长如果每个开发者各自配一套 Key团队里很快会出现额度混乱和审计盲区。我的做法是走 TaoToken 的统一通道把模型调用收敛到一个入口。TaoToken 在这里的作用是提供一个兼容 Anthropic 接口规范的 API 地址Claude Code 只需要改base_url和api_key两个字段就能接入不用改客户端逻辑。官网入口在 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 根地址是 https://taotoken.net/api 注意 API 地址不带 UTM 参数配置时别把查询串带进去。先拿 Key进控制台 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_campaignrewriteutm_content 在 API Keys 页面 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_campaignrewriteutm_content 创建一个项目级 Key。建议按「迁移项目」单独建 Key而不是复用个人 Key这样后面做用量对比时能直接看出重写任务消耗了多少 token。注意Key 只在创建时完整显示一次复制后立刻写进本地环境变量或密钥管理工具不要提交到仓库。3. 可复制配置config.toml 骨架与 Claude Code 接入Claude Code 的配置分两层一层是模型通道一层是项目级行为约束。先给模型通道的config.toml骨架放在用户配置目录下Linux/macOS 通常是~/.config/claude/config.tomlWindows 在%APPDATA%\claude\config.toml。# ~/.config/claude/config.toml # 统一模型通道配置供 Claude Code 读取 [api] # TaoToken 兼容 Anthropic 规范的根地址不要带 UTM 查询串 base_url https://taotoken.net/api # 从控制台创建的 Key建议用环境变量注入而非硬编码 api_key ${TAOTOKEN_API_KEY} # 重写任务上下文长超时给足 timeout_seconds 120 max_retries 3 [model] # 主力模型用于生成 Rust 实现 primary claude-sonnet-4-5 # 审查模型用于检查 FFI 边界和 unsafe 块 reviewer claude-sonnet-4-5 max_tokens 8192 temperature 0.2 [project] # 迁移项目根目录Claude Code 只在这个范围内读写 root /workspace/bun-rust-migration # 排除构建产物和第三方依赖避免污染上下文 exclude [target/, node_modules/, vendor/, *.lock]环境变量这样注入避免 Key 落盘# Linux/macOS export TAOTOKEN_API_KEYsk-你的项目级Key # Windows PowerShell $env:TAOTOKEN_API_KEY sk-你的项目级Key项目级约束我单独放一个CLAUDE.md在仓库根目录把 FFI 规则写死减少每次对话重复交代# 迁移项目约束 ## FFI 规则 - 所有跨语言结构体必须标注 #[repr(C)] - 所有 extern C 函数必须配对的释放函数 - unsafe 块必须写 Safety 注释说明调用者前置条件 ## 禁止 - 禁止在 FFI 边界使用 Rust 默认布局的结构体 - 禁止在 Bun 主线程直接 block_on 异步运行时 - 禁止引入与 libuv 事件循环冲突的全局 runtime配好后用模型对话入口 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_campaignrewriteutm_content 先做一次连通性确认发一句「返回当前配置的模型名」能正常回就说明通道通了。这一步别跳过很多后续「生成结果为空」的问题其实是 Key 或 base_url 写错。4. FFI 边界Rust 重写里最容易翻车的地方Zig 默认按 C 布局排结构体字段Rust 默认会重排字段来省内存。这个差异在纯 Rust 代码里无所谓但一旦跨 FFI 边界字段错位就是静默的数据损坏编译期不报错运行时才炸。所以迁移第一步不是写业务逻辑而是把所有跨边界类型用#[repr(C)]钉死。// ffi_types.rs // 所有跨 FFI 边界的类型必须显式指定 C 布局 use std::os::raw::{c_char, c_int}; /// 对应 Zig 侧的 BunString 结构 /// 字段顺序必须与 Zig 定义完全一致禁止调整 #[repr(C)] pub struct BunString { pub ptr: *const c_char, pub len: usize, pub is_utf16: c_int, } /// 对应 Zig 侧的 BunError 枚举 /// 用 c_int 承载避免 Rust 枚举布局差异 #[repr(C)] pub struct BunError { pub code: c_int, pub message: *const c_char, } /// 从 C 字符串安全转换空指针返回 None /// /// # Safety /// 调用者必须保证 ptr 指向以 null 结尾的有效 C 字符串 /// 且该内存在本次调用期间不被释放 pub unsafe fn cstr_to_stra(ptr: *const c_char) - Optiona str { if ptr.is_null() { return None; } std::ffi::CStr::from_ptr(ptr).to_str().ok() }配套的释放函数必须成对出现这是我在实际项目里强制 review 的一条// ffi_alloc.rs use std::os::raw::c_char; /// 分配 C 字符串调用方负责用 bun_free_string 释放 #[no_mangle] pub extern C fn bun_alloc_string(src: *const c_char, len: usize) - *mut c_char { if src.is_null() || len 0 { return std::ptr::null_mut(); } unsafe { let buf libc::malloc(len 1) as *mut u8; if buf.is_null() { return std::ptr::null_mut(); } std::ptr::copy_nonoverlapping(src as *const u8, buf, len); *buf.add(len) 0; buf as *mut c_char } } /// 释放 bun_alloc_string 分配的内存 /// /// # Safety /// ptr 必须来自 bun_alloc_string且只能释放一次 #[no_mangle] pub extern C fn bun_free_string(ptr: *mut c_char) { if !ptr.is_null() { unsafe { libc::free(ptr as *mut libc::c_void) } } }用 Claude Code 生成这类代码时我会在 prompt 里明确要求「每个extern C函数必须输出对应的释放函数并在注释里写明所有权转移方向」。实测下来不加这条约束生成结果里大约三成会漏掉释放路径后面排查内存泄漏非常费时间。5. 异步运行时冲突别让两个事件循环打架Bun 的事件循环基于 libuvRust 生态里 tokio 有自己的 reactor。两者直接混用轻则任务饿死重则死锁。核心原则只有一条调度权只能有一个主人。Bun 是宿主libuv 是主循环Rust 侧的异步任务必须通过通道投递回主循环执行而不是自己起一个 runtime 抢线程。// bridge.rs // 把 Rust 异步任务投递到 Bun 的 libuv 主循环 use std::sync::mpsc::{self, Sender, Receiver}; use std::sync::Mutex; /// 任务类型闭包在 Bun 主线程执行 type Task Boxdyn FnOnce() Send static; pub struct EventLoopBridge { tx: SenderTask, rx: MutexReceiverTask, } impl EventLoopBridge { pub fn new() - Self { let (tx, rx) mpsc::channel(); Self { tx, rx: Mutex::new(rx) } } /// 从任意线程投递任务不阻塞 pub fn post(self, task: Task) - Result(), static str { self.tx.send(task).map_err(|_| event loop closed) } /// 由 Bun 主循环在每帧调用消费待执行任务 /// 返回本次执行的任务数便于监控积压 pub fn drain(self) - usize { let rx self.rx.lock().expect(bridge mutex poisoned); let mut count 0; while let Ok(task) rx.try_recv() { task(); count 1; // 单帧最多执行 64 个避免阻塞主循环 if count 64 { break; } } count } }在 Bun 侧注册这个 drain 调用伪代码形态如下关键是把它挂到已有的 tick 回调上而不是新开定时器// bun_side.zig示意实际迁移时逐步替换 const bridge import(rust_bridge); // 在已有的 event loop tick 中调用 fn onTick() void { const executed bridge.drain(); if (executed 0) { // 记录积压指标超过阈值告警 metrics.record(bridge.drain, executed); } }注意不要在 Rust 侧调用tokio::runtime::Runtime::block_on去等 Bun 的回调那会直接死锁。所有跨边界等待都必须走通道 主循环 drain 的模式。6. 验证请求与迁移前后基准对比配置和骨架就位后必须有一套可重复的验证动作否则「重写完了」只是主观判断。我通常分三步通道连通性、FFI 正确性、性能回归。第一步确认模型通道可用用 curl 直接打 API排除客户端干扰curl -sS https://taotoken.net/api/v1/messages \ -H x-api-key: ${TAOTOKEN_API_KEY} \ -H anthropic-version: 2023-06-01 \ -H content-type: application/json \ -d { model: claude-sonnet-4-5, max_tokens: 64, messages: [{role: user, content: reply with ok}] }返回体里能看到content字段且无error说明 Key 和地址都对。如果返回 401先查 Key 是否带上了多余空格返回 404检查 base_url 是不是误加了/v1之外的路径。第二步FFI 正确性用 Rust 侧单测覆盖重点测空指针、超长字符串、非 UTF-8 输入三类边界#[cfg(test)] mod ffi_tests { use super::*; use std::ffi::CString; #[test] fn test_null_input_returns_none() { let result unsafe { cstr_to_str(std::ptr::null()) }; assert!(result.is_none()); } #[test] fn test_roundtrip_alloc_free() { let src CString::new(bun-rust).unwrap(); let ptr bun_alloc_string(src.as_ptr(), src.as_bytes().len()); assert!(!ptr.is_null()); let back unsafe { cstr_to_str(ptr) }.unwrap(); assert_eq!(back, bun-rust); bun_free_string(ptr); } }第三步性能回归。迁移前后各跑同一组基准关注启动耗时、内存峰值、FFI 调用吞吐三个指标。用 hyperfine 做启动对比# 迁移前Zig 版本 hyperfine --warmup 3 --runs 20 ./bun-zig --version # 迁移后Rust 版本 hyperfine --warmup 3 --runs 20 ./bun-rust --versionFFI 吞吐用 criterion 写一个微基准直接测桥接层的投递与 drain// benches/bridge_bench.rs use criterion::{criterion_group, criterion_main, Criterion}; fn bench_post_drain(c: mut Criterion) { c.bench_function(bridge_post_drain_1k, |b| { let bridge EventLoopBridge::new(); b.iter(|| { for _ in 0..1000 { bridge.post(Box::new(|| {})).unwrap(); } bridge.drain(); }); }); } criterion_group!(benches, bench_post_drain); criterion_main!(benches);跑完把三组数据记进迁移日志任何一项回退超过 5% 都要定位原因而不是「先合了再说」。长期做这类重写和 Agent 辅助编码的话Coding Plan https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_campaignrewriteutm_content 的额度模型比按次调用更适合持续跑基准和批量生成。7. 本篇常见错排查报错一undefined symbol: bun_alloc_string链接期找不到符号通常是 Rust 侧漏了#[no_mangle]或者 crate 类型没设成cdylib。检查Cargo.toml[lib] name bun_rust_bridge crate-type [cdylib, staticlib]报错二结构体字段读到乱码九成是漏了#[repr(C)]。用std::mem::size_of在两侧各打一次日志对比尺寸不一致就说明布局没对齐。报错三程序卡死无响应典型的事件循环互等。检查是否有 Rust 侧block_on等待 Bun 回调或者 Bun 主线程同步等待 Rust 异步结果。改成通道投递 drain 模式。报错四Claude Code 生成结果为空或截断先确认max_tokens是否够用重写任务建议不低于 8192。再确认base_url没带 UTM 查询串带了的请求会被网关拒绝。接入细节可对照接入文档 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_campaignrewriteutm_content 逐项核对。报错五基准结果波动大检查是否在 CI 容器里跑基准CPU 配额和邻居干扰会让数据失真。基准必须在固定规格的机器上跑且关闭后台任务。8. 迁移收尾把可控性落到流程里Bun 这次重写真正值得借鉴的不是「用了 Claude Code」而是把 AI 生成放进了可验证的工程流程FFI 类型有#[repr(C)]约束、异步有单一调度主人、性能有前后基准对比。这三条缺任何一条重写都会变成不可控的技术债。如果你也在做类似迁移建议从非核心模块试点先把桥接层和基准跑通再逐步扩大范围。Claude Code 配合 TaoToken 统一通道能把重复的翻译和审查工作压缩掉但边界决策和验收标准必须由人定。最后留一个实用习惯每次合并前跑一遍cargo clippy -- -D warnings和 FFI 单测把问题挡在编译期比运行时排查省太多时间。