
2026 年的 iOS 平台上出现了一个评价极高的独立应用:MacStories 主编 Federico Viticci 称它是"最近一段时间见过最令人印象深刻的独立 App",中文社区则给它贴上了"iPhone 最强 Agent 软件"的标签。这个应用就是 OpenMinis——一个把 Claude、GPT、Gemini 等主流大模型装进原生移动体验,并且给它们一台真实计算机用的开源项目。
几个月前我也写过一篇文章介绍这个项目Open Minis: iOS 端的 AI Agent 新物种,当时作者还没有开源,现在作者已经把它开源出来,而且功能更加完善和稳定。
所谓"真实的计算机"不是修辞:OpenMinis 在你的手机里跑了一个完整的 Alpine Linux 沙箱。Agent 可以在里面 apk add 装包、跑 Python 和 Node 脚本、读写真实文件;同时还能通过一组 apple-* / android-* 命令直接调用 HealthKit、日历、提醒事项、HomeKit、NFC 等系统能力。更难得的是,整个项目以 GPLv3 完全开源,源码里沉淀了大量"被真实用户和真实 bug 教育过"的工程细节。
本文基于对 OpenMinis/OpenMinis 仓库(iOS 侧约 18.7 万行 Swift、Android 侧约 15.4 万行 Kotlin)的完整通读,从背景、功能到技术原理做一次深度拆解,重点落在它的三方依赖运用与底层实现机制上。

项目地址:https://github.com/OpenMinis/OpenMinis官网:https://openminis.appLicense:GPLv3(因链接 GPLv3 的 iSH 与 GPLv2 的 PRoot)
一、背景:为什么需要这样一个东西
今天的手机 AI 应用,绝大多数是"聊天框 + 云端 API":你说话,它打字,完。少数能做"工具调用"的,工具也跑在云端服务器上——访问不了你的健康数据,碰不到你的日历,更没法操作你手机里的文件。
OpenMinis 的立场恰好相反:Agent 应该在设备本地拥有一台真电脑。README 里给出的典型用法很能说明问题:
拍一张餐食照片,Agent 识别菜品、估算热量和宏量营养素,写入 Apple Health; 通过快捷指令定时触发:抓取你的 X 时间线、总结、合成语音,当作闹钟播给你听; 挂载你的 Obsidian 仓库,Agent 像操作普通工作区一样研究、整理、回写 Markdown 笔记; 把任意分享内容(网页、消息)经 iOS 分享面板交给 Agent,自动建成带时间地点的日历事件。
这些场景的共同点是:需要真实执行环境 + 深度系统集成。而 iOS/Android 的沙箱模型恰恰最不允许这两件事。OpenMinis 的全部技术含金量,就在于它如何在两个平台的镣铐之下把这两件事做成了。
项目还有一个很鲜明的态度(写进 README):"在 AI 时代,技术设计和代码不再是产品的护城河,最好的 Agent 来自与使用者的高频反馈循环。"所以仓库干脆全量开源——它是私有开发树的公开镜像,不接受 PR,但接受 Issue。(很个性啊)

二、功能总览与整体架构
功能层面,OpenMinis 可以概括为六块:
- 自带模型(BYOM)
Claude、GPT、Gemini、xAI、Kimi、OpenRouter 等,支持 API Key 与 OAuth 账号登录双通道; - 真 Linux shell
设备端沙箱化 Alpine Linux,Agent 可装包、跑脚本、操作真实文件; - 设备集成
健康、日历、提醒、通讯录、HomeKit、蓝牙、剪贴板、媒体、闹钟等,全部以工具形式暴露给 Agent; - 浏览器自动化
Agent 可以代替你浏览和操作网页(WKWebView 池 + JS 注入); - Skills 与记忆
兼容 Anthropic Agent Skills 规范的技能系统(SKILL.md 按需加载),加跨会话持久记忆; - 工作区(Workspaces)
以 minis://workspace/寻址的独立上下文,还实现了 MCP 客户端、Siri/快捷指令集成、iCloud/LAN 同步、灵动岛 Live Activity 等。
整体架构上,双端共享同一套设计:UI 层(SwiftUI / Compose)→ Agent 运行时(各自平台的大 ViewModel)→ Provider 层(各家 LLM 的 SSE 流式客户端)→ 沙箱层(iOS 用 iSH 模拟内核,Android 用 PRoot)→ 原生卸载层(NativeOffloads)。

这张图的左右两侧揭示了全项目最重要的一个技术分野:同样是"在手机上跑 Alpine Linux",iOS 和 Android 用了两种本质不同的方案——iOS 是用户态 CPU 模拟 + 自实现内核 ABI,Android 是基于 ptrace 的 user-space chroot。我们逐一拆开看。
三、核心黑科技(一):iOS 上的 iSH-ARM64 嵌入式沙箱
3.1 不是原版 iSH,而是 ARM64 fork + Asbestos 引擎
原版 iSH 是著名的"x86 用户态模拟器",用 gadget 汇编 JIT 在 iOS 上模拟 i386 Linux。OpenMinis 用的是自家 fork OpenMinis/ish-arm64,关键变化有三:
Guest 架构从 i386 换成 ARM64 (aarch64),guest OS 是 Alpine Linux aarch64——guest 与 host 同架构,大量指令可以直译执行,这是它敢在手机上跑 Python/Node 的前提; 执行引擎换成自研的 Asbestos(threaded-code 解释器)。因为 iOS 禁止 RWX 内存,不可能做真正的二进制翻译 JIT,只能是 dispatch-loop 解释器; 以静态库( libish.a内核 +libish_emu.a模拟器 +libfakefs.a虚拟文件系统)形态链进 App 进程,而不是独立进程。
构建脚本里的 meson 配置是最直接的证据(deps/build_ish.sh:203-210):
meson setup "$BUILD_DIR" \ --cross-file "$CROSS_FILE" \ --buildtype="$MESON_BUILDTYPE" \ -Dlog="" \ -Dlog_handler=nslog \ -Dkernel=ish \ -Dengine=asbestos \ -Dguest_arch=arm64"iSH 内核与 App 同进程"这一个决定,是整个 iOS 侧工程复杂度的源头——后面要讲的 JIT 崩溃恢复、FFmpeg 全局状态复位、stdio 线程局部重定向,全都是在为这个决定"还债"。
3.2 冷启动:一次内核 boot 都干了什么
ISHKernel.boot()(src/ios/iSH/ISHKernel.m:334-451)的序列非常"操作系统的味道":
install_jit_crash_handler(); // 先于一切 JIT 代码安装崩溃恢复die_handler = embedded_die_handler; // 替换 iSH 的 die()(原版会 abort 杀掉整个 App)err = mount_root(&fakefs, _dataPath.fileSystemRepresentation); // 挂载 fakefserr = become_first_process(); // 让自己成为 guest 的 PID 1current->thread = pthread_self();[self createDeviceNodes]; // 手工造 /dev/tty1-7、/dev/ptmx、/dev/null …FsApplyOverlay(); // 应用 RootfsPatch.bundle 补丁(幂等)do_mount(&procfs, "proc", "/proc", "", 0);do_mount(&devptsfs, "devpts", "/dev/pts", "", 0);[self mountDnsConfig]; // /etc/resolv.conf 文件级 bind mount 到宿主exit_hook = handle_process_exit;tty_drivers[TTY_CONSOLE_MAJOR] = &ish_console_driver;set_console_device(TTY_CONSOLE_MAJOR, 1);err = create_stdio("/dev/console", TTY_CONSOLE_MAJOR, 1);// 27 个 native offload 全部在这里注册ffmpeg_offload_register();calendar_offload_register();// … weather / healthkit / homekit / nfc / bluetooth …debug_offload_register();几个值得展开的点:
- fakefs
guest 的根文件系统落在 ~/Documents/alpine-rootfs/,其中data/是真实文件树,meta.db(SQLite,表paths(path, inode)+stats(stat))存权限、属主、符号链接等 POSIX 元数据——因为 iOS 文件系统存不了这些。副作用是:App 侧直接往 rootfs 写文件时,必须同步登记meta.db,否则内核"看不见"这个文件(RootfsManager.ensureFakefsMetadata,RootfsManager.swift:299-349)。 - DNS
/etc/resolv.conf以文件级 bind mount 指向宿主目录,内容从 iOS 的res_ninit/res_getservers实时读出写入,兜底 8.8.8.8。 - PID 1
become_first_process()把调用线程变成 guest 的 init 进程,后续所有 guest 进程都 fork 自它。
3.3 嵌入式改造的精髓:JIT 崩溃恢复
原版 iSH 是独立进程,guest 崩了 _exit 就完事;嵌进 App 后,模拟器线程的一次 SIGSEGV 会杀掉整个应用。OpenMinis 的解法是一个硬核信号处理器(ISHKernel.m:88-124):用 ucontext 改写寄存器,把崩溃的 JIT 线程弹回 trampoline:
static void jit_crash_handler(int sig, siginfo_t *info, void *ctx) {#ifdef __aarch64__ if ((sig == SIGSEGV || sig == SIGBUS) && in_jit) { ucontext_t *uc = (ucontext_t *)ctx; uint64_t cpu_ptr = uc->uc_mcontext->__ss.__x[1]; // _cpu 结构体在 x1 uint64_t x7 = uc->uc_mcontext->__ss.__x[7]; uint64_t x10 = uc->uc_mcontext->__ss.__x[10]; uint64_t guest_addr = (x7 - x10) & 0xffffffffffffULL; uint64_t esr = uc->uc_mcontext->__es.__esr; int was_write = (esr & 0x40) != 0; *(uint64_t *)(cpu_ptr + CRASH_CPU_segfault_addr) = guest_addr; *(int *)(cpu_ptr + CRASH_CPU_segfault_was_write) = was_write; *(uint64_t *)(cpu_ptr + CRASH_CPU_pc) = (uint64_t)jit_saved_pc; uint64_t exit_sp = *(uint64_t *)(cpu_ptr + CRASH_LOCAL_jit_exit_sp); uc->uc_mcontext->__ss.__sp = exit_sp; uc->uc_mcontext->__ss.__pc = (uint64_t)jit_crash_trampoline; ...这段代码硬编码了 cpu_state 的字段偏移,从宿主 ESR 寄存器推断这次访存是读还是写,然后改写 SP/PC 让执行流回到 fiber 的退出点——把一次致命的段错误降格成一次"guest 内存访问异常"。
配套的还有两个细节设计(ISHKernel.m:146-198):非 JIT 的 iSH 线程崩溃时,只冻结线程不杀进程——用 __thread int ish_thread_marker 区分 iSH 线程与 App 其它线程,是 iSH 线程就屏蔽所有信号后 select() 永久休眠(因为实测 pthread_exit() 在某些线程状态下会触发 SIGTRAP 连累整个进程);不是 iSH 线程则恢复默认 handler 重新 raise,保证 Swift 运行时自己的崩溃仍能被系统 crash reporter 正常捕获。
3.4 命令执行:不走 PTY,直接操纵内核 fd 表
Agent 执行 shell 命令的链路是:AIChatViewModel → ISHExecutionCoordinator(Swift actor,负责超时/取消/会话挂载)→ ISHShellExecutor(ObjC,进程级执行)→ iSH 内核。
最精妙的一步在 ISHShellExecutor.m:236-282:它不使用 PTY,而是创建一个 guest 进程后,直接操纵内核 fd 表,把 guest 的 fd 0/1/2 绑到 host 的 pipe 上:
struct fd *stdin_fd = adhoc_fd_create(&realfs_fdops);if (stdin_fd) { int real_fd = stdinData ? dup(stdinPipe[0]) : open("/dev/null", O_RDONLY); ... stdin_fd->real_fd = real_fd; task->files->files[0] = stdin_fd; // guest 的 fd0 = host pipe 的读端}// stdout/stderr 同理,dup 写端stdout_fd->real_fd = real_fd;task->files->files[1] = stdout_fd;...task->files->files[2] = stderr_fd;realfs_fdops 让 guest 的 read/write 直接落到 host fd 上。好处是输出零 TTY 噪音(不回显、无 ANSI 提示符),stdout/stderr 天然分离——这正是 shell_execute 工具能拿到干净输出的原因。reader 线程用 poll() 轮询两根 pipe(500ms 超时),做 UTF-8 安全截断(不完整的多字节序列留到下一批),逐行回调 UI。
取消链路同样讲究(ISHShellExecutor.m:555-628):杀进程组时按 pgid + 祖先链双重匹配(busybox ash 会给子 shell setpgid(0,0) 做 job control,只按 pgid 杀会漏掉 sleep 之类的孙进程——注释里点名"这就是早期 Stop 按钮杀不掉 sleep 的原因"),拒绝 pid ≤ 1(防止误删整个内核),SIGTERM 后 200ms 升级 SIGKILL,且升级前重验 pid 未被回收复用。
另一个"被 busybox ash 教做人"的例子(ISHExecutionCoordinator.swift:299 + ISHShellExecutor.m:123-132):命令不是作为参数而是通过 stdin 喂给 /bin/sh,脚本被包装成:
let scriptContent = "cd /root\n({ exec 0</dev/null; } 2>/dev/null || true; \(command)\n)\n"这样既绕开 shell 引号地狱,又让子进程拿到立即 EOF(防止 stdio 型 MCP server 继承已耗尽的 pipe 永远挂起)。末尾的 \n 也不能省——busybox ash 的 heredoc 解析器在终止行后缺换行时 100% 报 unexpected end of file。
3.5 每会话文件路由:fs_context 钩子
多会话并发时,每个会话的 /var/minis/workspace/ 必须指向各自的宿主目录,又不能靠切换 bind mount(那样会话切换就要卸载重挂)。OpenMinis 的方案是给每个会话分配一个 u64 的 fs_context token,fork 时打在进程组上并随 fork 继承;然后在 fakefs 的路径翻译热路径上挂一个 C 层钩子(Agent/ISH/MinisFsRouter.swift:16-113):
final class MinisFsRouter: @unchecked Sendable { /// 每会话独立路由的 guest 前缀;memory/skills/shared 是全局的 private let perSessionBuckets: [(linuxPrefix: String, hostSubdir: String)] = [ (AIChatViewModel.minisOffloadsLinuxDir, "offloads"), (AIChatViewModel.minisAttachmentsLinuxDir, "attachments"), (AIChatViewModel.minisWorkspaceLinuxDir, "workspace"), (AIChatViewModel.minisBrowserLinuxDir, "browser"), ] private var contextToSid: [UInt64: String] = [:] func installHook() { ISHKernel.shared.installPathTranslateHandler { [weak self] guestPath, fsContext in return self?.translate(guestPath: guestPath, fsContext: fsContext) } ISHKernel.shared.installPathReverseHandler { [weak self] hostPath in return self?.reverse(hostPath: hostPath) } } // 热路径:必须无阻塞——哈希查表 + 几个字符串操作 private func translate(guestPath: String, fsContext: UInt64) -> String? { guard fsContext != 0, let sid = sid(for: fsContext) else { return nil } return hostPath(forGuest: guestPath, sid: sid) }}于是 guest 进程访问 /var/minis/workspace/report.csv 时,被透明改写到 ~/Library/MinisChat/minis/<sid>/workspace/report.csv。会话切换不用动任何挂载,多个会话的 guest 进程并发跑也不串数据。 一个有趣的实现细节:ObjC 桥接层的注释(ISHKernel.m:1277-1303)坦白这些 block 是"有意泄漏"的——trampoline 跑在无锁 worker 线程热路径上,释放被替换的 block 有竞态,而安装次数屈指可数,泄漏可控。
iOS 侧沙箱的全貌总结成一张图:

四、核心黑科技(二):Android 上的 PRoot 方案
Android 侧不需要 CPU 模拟——直接上 PRoot(user-space chroot,fork 自 Termux)。guest 跑的是原生 aarch64 指令,PRoot 只用 ptrace 拦截子进程的系统调用,重写路径和 uid。没有模拟开销,这是它比 iOS 方案"占便宜"的地方;但把 PRoot 塞进一个合规的 Android App 里,工程技巧一点不少。
4.1 把一个可执行文件伪装成 .so
Android 应用私有目录默认不可执行(noexec),那 proot 二进制放哪?OpenMinis 用了一个非常巧的打包技巧:把 proot 编译产物改名成 libproot.so 放进 jniLibs/arm64-v8a/。APK 里的 .so 会被 PackageManager 自动解压到 nativeLibraryDir——一个拥有 exec 权限的目录。配合 manifest 的 android:extractNativeLibs="true",运行时直接 execve 这个"共享库":
// RootfsManager.kt:43val prootBinary: File = File(context.applicationInfo.nativeLibraryDir, "libproot.so")同一份二进制还会放一份到 assets/proot-aarch64 兜底,且 build.gradle.kts:97 用 noCompress += ["tar.gz", "proot-aarch64"] 保证它们不被 AAPT 压缩。构建侧(deps/build_proot.sh)还有个细节:PRoot 依赖 Samba 的 talloc 内存分配器,而 Samba 用 waf 构建系统——脚本干脆只单文件编译 talloc.c,并手写一个 80 行的 replace.h 兼容 shim,让 talloc 脱离 Samba 其余部分直接对 bionic 编译。
4.2 PRootKernel:一个"不是内核的内核"
iOS 的 ISHKernel 是一个持久内核对象;Android 的 PRootKernel(920 行)则只是配置中心 + 命令行构造器——因为 PRoot 是 process-per-command 模型。文件头注释写得很直白:
/** * PRoot configuration holder and command builder. * Corresponds to iOS ISHKernel — but since PRoot is process-per-command * (not a persistent kernel), this object holds configuration state * and builds proot command lines. */它拼出的命令行长这样(PRootKernel.kt:586-648):
<proot> -0 --link2symlink -r <rootfs> -b /dev -b /proc -b /sys -w /root [-b <host>:<linux> …] --native-offload=<socket>:<handler1,handler2,…> /bin/sh -c "<command>"两个值得单独说的 flag:
-0:伪装 uid 0(root),让 apk等工具开心;--link2symlink:Android /data分区拒绝 app uid 的跨目录 hardlink,而 Alpine 的apk装 binutils/gcc 时全靠把 ar/ld/nm hardlink 到 busybox——不加这个 flag 每次安装都是 "Permission denied"。
PRoot 的 -b 挂载没有只读修饰符,用户挂载的 Obsidian 仓库又想只读保护怎么办?OpenMinis 的做法是用户态 trick 补能力缺口:在 guest 的 /usr/local/bin/ 里给 touch/tee/cp/mv/mkdir/rm 等命令装一层 sh wrapper,wrapper 运行时 source /var/minis/.mount-readonly-prefixes 配置,命中只读前缀即 exit 1 并给出可读错误;纯 > 重定向则靠宿主文件系统的 EACCES 自然兜底(PRootKernel.kt:852-909)。
4.3 双执行通道与 marker 协议
Android 侧有三条执行通道:
- 一次性命令
( ShellExecutor):ProcessBuilder直接跑,超时destroyForcibly(); - 每会话长驻 shell
( PersistentShell):agent 的shell_execute真正走的通道——每个 chat session 一个proot … /bin/sh进程,环境变量、cwd、已装包在会话内持续存在; - 交互式 PTY
( pty_bridge.cJNI 封装 bionicforkpty()):App 内终端页用,vi/top/gh auth login靠它工作。
长驻 shell 最大的难题是:怎么知道一条命令跑完了、退出码是多少? 答案是 marker 协议(PersistentShell.kt:277-278):
val marker = UUID.randomUUID().toString().take(8)val wrappedCommand = "$command\necho \"__MINIS_DONE_${marker}_EXIT_\$?__\"\n"读线程扫描输出流里的 __MINIS_DONE_<id>_EXIT_<code>__,既界定了输出边界又拿到了退出码。配套还有一个隐蔽工程点:长驻 shell 会残留上次注入的环境变量,所以每次执行前按上次的 key 集合先 unset 再 export,模拟 iOS 侧"每条命令一个新进程"的天然隔离。
4.4 Native Offload:socket 协议 + execve 改写
Android 的原生卸载机制甚至比 iOS 更绕一层。OpenMinis 给 PRoot 打了 native_offload 扩展补丁:guest 里 execve("android-calendar", …) 被拦截,argv/env/cwd 通过 abstract unix socket 发给 App 进程内的 Kotlin 服务器;服务器执行对应 handler,把输出写进 rootfs 的 /tmp/.native-offload-<pid>-<seq>,回复 (exit_code, tmpfile_path);然后 PRoot 把原 execve 改写成 /bin/cat <tmpfile>——于是 guest 进程"看到"的就是一次普通命令执行。协议是自定义二进制的(sandbox/NativeOffload.kt:56-58):
private const val MAGIC_REQ = 0x46464F4E // 'N' 'O' 'F' 'F' little-endianprivate const val MAGIC_RSP = 0x52464F4E // 'N' 'O' 'F' 'R'private const val VERSION = 1socket bind 还带指数退避重试(0/50/100/200/400/800ms)——因为抽象 socket 在上一个 App 进程被 OOM 杀掉后不会立刻释放。
Android 侧沙箱全貌:

4.5 Shizuku:Android 独有的特权面
Android 端还有一层 iOS 没有的能力面:通过 Shizuku(dev.rikka.shizuku:api:13.1.5,MIT),以 uid 2000(shell)甚至 root 身份执行 pm/am/cmd/settings/dumpsys/input 等特权命令,包装成沙箱内 CLI android-shizuku-cli,12 个子命令组,支持 --format json|text|csv。每一次调用还要先过 OffloadGate.allow() 三态权限门(ASK_ONCE 会弹 UI 对话框挂起等待用户确认)。manifest 注释里还提到:AXManager/Axeron 是 Shizuku 协议的 drop-in 实现,复用 rikka SDK 即可零额外依赖兼容。
五、Agent 运行时:一个被真实 bug 反复捶打的 loop
5.1 双端"人肉 parity"
OpenMinis 没有跨平台共享代码的 Agent 层——iOS 的 Agent 运行时住在 AIChatViewModel.swift(5830 行主文件 + 19 个 extension 拆分文件),Android 住在 ChatViewModel.kt(9844 行)。两端靠大量行为级注释做对齐,Android 代码里到处是 // Mirrors iOS AIChatViewModel.swift:6229-6259 这样的行号互引,形成一套独特的"人肉 parity 协议"。连同一份 bashism 检测规则都是双端共享的 JSON(src/shared/bashism/bashism_rules.json,构建期分别拷进 iOS bundle 和 Android assets)。
5.2 统一抽象:一套事件协议罩住所有厂商
Provider 层的关键设计是把 Anthropic/Gemini/OpenAI 三家的流式协议映射到同一组规范类型上(Providers/AgentProvider.swift:16-219):
// 统一流事件:三家厂商都映射到这里enum AgentStreamEvent: @unchecked Sendable { case contentBlockStart(AgentBlockStart) case textDelta(String) case toolInputDelta(name: String, accumulated: String) case toolCallComplete(id: String, name: String, args: [String: Any], metadata: ToolCallMetadata?) case usage(LLMUsage) case thinkingDelta(String) case reasoningContent(String) // Kimi/DeepSeek 的 opaque reasoning,须回显 case reasoningEcho(ReasoningEcho) // OpenAI Responses 加密 reasoning case done(stopReason: AgentStopReason) // endTurn / toolUse / maxTokens / refusal}protocol AgentProvider { var name: String { get } var model: LLMModel { get } var defaultMaxTokens: Int { get } func streamAgentMessageClamped(messages: [AgentMessage], systemPrompt: String?, tools: [AgentToolDefinition], maxTokens: Int, thinkingLevel: ThinkingLevel) async throws -> AsyncThrowingStream<AgentStreamEvent, Error>}Android 侧是完全对称的 sealed class LLMStreamChunk(data/model/LLMStreamChunk.kt:5-32),工具定义 AgentToolDefinition 自带 toAnthropicJson()/toGeminiJson()/toOpenAIJson() 三个序列化器。各厂商的"怪癖"都在各自的映射层消化:Gemini 不给工具调用 id(客户端合成 UUID)、Gemini 即使带 function call 也发 STOP(有工具调用时必须改判为 .toolUse)、Gemini 3 的 thoughtSignature 必须持久化并在会话恢复时回填、某些 OpenAI 兼容网关会给并行 tool_calls 发重复 id(客户端改名 <id>-2、<id>-3)……
5.3 主循环骨架
一次请求的完整循环(双端结构一致):

iOS 侧骨架(AIChatViewModel.swift:4477-4550):
loopLabel: while turnCount < Self.maxAgentTurns { // 上限 200 defer { turnCount += 1 } try Task.checkCancellation() // 裁剪旧图片 → offload 旧 tool 输出 → 循环内 auto-compact 决策 trimOldImagesFromHistory() offloadContextIfNeeded(model: activeModelForOffload, lastContextTokens: turnUsage.latestContextTokens) switch checkContextBeforeSend() { case .ok: break case .needsCompact: if compactionsThisLoop < Self.maxInLoopCompactions, let anchorId = compactAnchorId { compactionsThisLoop += 1 await compactBefore(anchorId, allowDuringProcessing: true) turnCount -= 1 // compact 是空间管理,不消耗 turn 配额 continue } fallthrough case .exhausted: appendSystemInfo(String(localized: "Context is full and could not be compacted further...")) canResume = true break loopLabel } let stream = try await streamWithGroupFallback(provider: provider, ...) // … 消费 SSE → 工具派发 → tool_result 回灌 → continue …}看点有三:turn 上限 200 写在 while 条件里而不是事后判断,并用 hitTurnLimit 标志位区分"打满上限"与"正常 break"(注释自承 v1.4.0-dev 曾因此误报);compact 不占 turn 配额但总上限不重置,防"无限 compact 循环";上下文管理按窗口大小分四档(<32K 不动、32-64K 剩 10K 触发 offload、64-128K 剩 20K offload/10K compact、≥128K 剩 40K/20K),旧工具输出落盘到 /var/minis/offloads/,模型需要时可用 file_read 找回。
5.4 并发工具执行 + 按序缝合
模型一轮可能返回多个工具调用。执行是并发的,但 Anthropic API 要求 tool_result 与 tool_use 严格同序配对——所以是"并发执行、按序交付"(AIChatViewModel.swift:5267-5320):
var outcomesByIndex: [Int: ToolExecOutcome] = [:]await withTaskGroup(of: (Int, ToolExecOutcome).self) { group in var added = 0 var harvested = 0 for (idx, tu) in toolEntries.enumerated() { // 限速:在途数低于 maxConcurrentTools(=10) 才放行 while added - harvested >= Self.maxConcurrentTools { if let pair = await group.next() { outcomesByIndex[pair.0] = pair.1 harvested += 1 } else { break } } group.addTask { [weak self] in let outcome = await self.executeSingleToolUse( tu: tu, msgIdx: msgIdx, tools: toolsSnapshot, batchBudget: imageBudgetActor) return (idx, outcome) } added += 1 } for await pair in group { outcomesByIndex[pair.0] = pair.1; harvested += 1 }}// 按原始 idx 缝回,保证 tool_use/tool_result 配对顺序for idx in 0..<toolEntries.count { guard let outcome = outcomesByIndex[idx] else { continue } toolResultParts.append(outcome.resultPart)}5.5 ToolLoopDetector:防跑飞熔断器
Agent 产品最怕模型"跑飞"——对着一个失败命令无限重试烧 token。OpenMinis 的熔断器有四类策略(Agent/ToolLoopDetector.swift:74-129,Android 侧 ToolLoopDetector.kt:98-159 完全对称):
func check(toolName: String, params: [String: Any]) -> LoopCheckResult { let argsHash = argsHashFor(toolName, params) // 策略1:unknown_tool_repeat —— 连续调用不存在的工具(幻觉工具) let unknownStreak = countUnknownStreakFromTail(toolName: toolName) if unknownStreak >= config.unknownToolThreshold { ... return .critical } // 策略2:global_circuit_breaker —— 同参同结果的"无进展"连续调用 let noProgressStreak = getNoProgressStreak(toolName: toolName, argsHash: argsHash) if noProgressStreak >= config.globalCircuitBreakerThreshold { ... return .critical } // 策略3:known_poll_no_progress —— 轮询类工具双阈值(10 警告 / 20 阻断) if isPollTool(toolName, params) { ... } // 策略4:generic_repeat —— 非轮询工具的累计同参次数 ...}配套的工程技巧也很细:算 args 哈希前剔除 tool_title(纯 UI 展示字段,模型每次都写得不一样);算结果哈希前用正则剥离 ISO 时间戳、elapsed/duration、request-id 等易变字段再 SHA256——否则"同样的失败"永远哈希不同,熔断永远不会触发。WARNING 级会把提示追加到 tool_result 文本里让模型自己看到,CRITICAL 级直接合成 [LOOP BLOCKED] 错误结果短路,同时保持 tool_use/tool_result 配平不破坏 API 协议。
5.6 流式性能:每一层都有节流梯子
流式渲染是移动端的性能重灾区(注释里记录了真实事故:Anthropic 慢轮次每秒 50-100 个 delta,早期版本每个 delta 都重组整个聊天列表直接 ANR;Pixel 4a 上 String += 的 O(n²) GC 曾打爆 256MB 堆)。修复是分层的:
- 累积器
:用 [String]数组惰性 join 或StringBuilder,杜绝String +=(iOS 注释算了笔账:eager input streaming 下 150-200 chunk/秒,50KB 输入会被+=复制成约 25MB memcpy); - 事件层
:tool input delta 的 yield 节流 80ms(最多约 12 事件/秒); - UI 层
:Android 按文本长度分级节流( ChatViewModel.kt:5843-5850):
fun textDeltaThrottleMs(len: Int): Long = when { len < 500 -> 150L len < 2_000 -> 300L len < 32_000 -> 500L len < 64_000 -> 1_000L len < 128_000 -> 1_500L else -> 2_000L}- 渲染层
:iOS 消息列表用 UICollectionView(CollectionViewMessageListV3)而非 SwiftUI LazyVStack 扛流式长会话。
5.7 弱网防御:静默断流检测
SSE 在弱网下最阴险的故障是 TCP 半开——URLSession/OkHttp 层不报错,流就这么"安静"了。两端的解法异曲同工。iOS 用每个事件 120 秒的"看门狗竞速"(AIChatViewModel+SSEStream.swift:271-285):
let stallTimeoutSeconds: TimeInterval = 120let iterBox = StreamIteratorBox(stream) // AsyncIterator 不 Sendable,装盒while true { let maybeEvent: AgentStreamEvent? = try await withThrowingTaskGroup(of: AgentStreamEvent?.self) { group in group.addTask { try await iterBox.next() } group.addTask { try await Task.sleep(nanoseconds: UInt64(stallTimeoutSeconds * 1_000_000_000)) throw StreamStallError(seconds: Int(stallTimeoutSeconds)) } let result = try await group.next()! group.cancelAll() return result } guard let event = maybeEvent else { break }Android 则用一个 Flow 操作符把"中继断连但 HTTP 正常结束的空气回复"转成可重试错误(provider/LLMProvider.kt:138-161):
fun Flow<LLMStreamChunk>.failOnSilentEmptyCompletion(providerName: String): Flow<LLMStreamChunk> = flow { var sawContent = false var sawFinishReason = false collect { chunk -> …; emit(chunk) } if (!sawContent && !sawFinishReason) { throw LLMError.TransientError("Server returned an empty response (connection dropped or upstream error)") }}两者殊途同归:把"静默失败"变成"显式错误",交给上层的自动重试(间隔 3/5/10/15/30 秒,UI 上有倒计时)和组内 fallback(一个 ModelGroup 配多个模型/端点,rate limit 或鉴权失败自动切下一个,同 modelId 切换时 UI 模型胶囊不闪烁)。
六、Native Offloads:把 iOS 框架伪装成 Linux CLI
6.1 机制:execve 拦截 + JSON envelope
这是全项目最优雅的设计之一。iOS 侧在内核 boot 时注册 27 个 handler(ISHKernel.m:413-445),每个 handler 对应 guest 里的一个命令:
// CalendarOffload.m 尾部void calendar_offload_register(void) { int err = native_offload_add_handler("apple-calendar", calendar_handler); if (err == 0) { noff_ensure_guest_stub("/usr/local/bin/apple-calendar"); // guest 里建可执行占位桩 NSLog(@"NativeOffloads: apple-calendar handler registered"); }}noff_ensure_guest_stub 在 fakefs 里创建 /usr/local/bin/apple-calendar 占位文件让 PATH 解析成立;guest 进程对它 execve 时被内核的 native_offload 层拦截,路由到 ObjC handler 直接调 EventKit/HealthKit/HomeKit/Vision 等原生框架,结果以统一 JSON envelope 写回 guest 的 stdout:
// NativeOffloadUtils.h/// 成功信封: {ok:true, tool, action, data, timestamp}NSDictionary *noff_json_envelope(NSString *tool, NSString *action, id data);/// 错误信封: {ok:false, tool, action, error:{code,message}, timestamp}NSDictionary *noff_json_error(...);对 LLM 来说这一切都是普通 CLI——system prompt 只教它一句:"apple-* 工具输出 JSON,--help 看用法"。iOS 侧有 apple-{calendar, location, weather, vision, open, clipboard, healthkit, photos, maps, nlp, alarm, media, speak, speech, device, homekit, notification, player, reminders, bluetooth, nfc} 等 22 个,外加 ffmpeg 和 minis-{model-use, sessions, browser-use, config, debug} 等元命令;Android 侧对应 android-* 家族 23 个 handler。HealthKit 还设计了 types 自发现 + batch 一次取多指标的渐进披露。需要 offload 的原因有二:能力(这些框架 API 只在原生侧可用)和性能(下一节的 FFmpeg 是最好的例子)。
6.2 案例深挖:FFmpeg 的三层 patch
Agent 想在沙箱里跑 ffmpeg -i a.mov b.mp4。如果在 Asbestos 解释器里执行 CPU 密集的转码,会慢一到两个数量级,而且用不了 VideoToolbox 硬编。所以 OpenMinis 把 FFmpeg 6.1.2 编成 iOS framework,guest 里敲 ffmpeg 时被拦截,直接调进程内的 ffmpeg_main()。代价是要把"一次性命令行进程"驯化成"App 进程内可反复调用的库函数",为此打了三层 patch:
Patch 1:main() 改名(build_ffmpeg.sh:141-158,sed 实现)——避免与 App 入口符号冲突:
patch_main_symbol() { FFMPEG_C="$FFMPEG_DIR/fftools/ffmpeg.c" if grep -q 'int ffmpeg_main(' "$FFMPEG_C"; then log_info "Already patched, skipping"; return fi sed -i '' 's/^int main(int argc, char \*\*argv)/int ffmpeg_main(int argc, char **argv)/' "$FFMPEG_C"}Patch 2:让 ffmpeg_main 可重入(deps/ffmpeg-patch/0001-ffmpeg-reset-statics.patch)——ffmpeg 靠大量全局/静态状态工作,第二次调用会看到上次残留的 received_sigterm 立即误退出。补丁把函数内 static 提升为文件级变量,并新增复位函数:
static int64_t print_report_last_time = -1;static int print_report_first = 1;static int64_t kbd_last_time = 0;void ffmpeg_reset_statics(void){ received_sigterm = 0; received_nb_signals = 0; atomic_store(&transcode_init_done, 0); ffmpeg_exited = 0; copy_ts_first_pts = AV_NOPTS_VALUE; print_report_last_time = -1; ...}Patch 3:线程局部 stdio 重定向(fftools_stdio_redirect.h,工程含金量最高的一层)——ffmpeg 用 fprintf(stderr, …) 打进度,常规做法是 dup2() 劫持 fd 1/2,但那会把全进程的 NSLog 一起劫走。OpenMinis 不改一行 ffmpeg 源码,而是用 -include 强制注入头文件,宏覆盖所有输出函数,再按 _Thread_local fd 决定写到管道还是透传:
/* -1 means "use the real stdio function" (passthrough). */extern _Thread_local int noff_stdout_fd;extern _Thread_local int noff_stderr_fd;...#define fprintf noff_fprintf#define printf noff_printf#define fputs noff_fputs#define vfprintf noff_vfprintf消费端 FFmpegOffload.m 再把闭环补上:pthread_mutex 串行化(全局状态非线程安全)、8MB 栈的专用 pthread、以及 argv 重写——-c:v libx264 → h264_videotoolbox、libx265 → hevc_videotoolbox,-crf N 按 bitrate ≈ 8000 * 2^((18-crf)/6) kbps 的经验公式折算成 -b:v(VideoToolbox 只认码率不认 CRF),剥掉 -preset/-tune/-pass 等 VT 不认识的选项。LAME 3.100 静态链入提供 MP3 编码(所以构建顺序必须是 LAME → FFmpeg,否则 MP3 支持被静默丢弃)。
七、Skills、记忆与 minis:// 协议
7.1 Skills:完整对齐 Anthropic Agent Skills 规范
OpenMinis 的 Skills 系统完全按 Anthropic 的规范实现:一个 skill = 一个目录,必有 SKILL.md(YAML frontmatter 的 name/description + Markdown body),可选 scripts/、references/、assets/。精髓是三级渐进披露:元数据常驻 system prompt → SKILL.md body 由模型用 file_read 按需加载 → bundled 资源用到再读。官方 README 甚至声称"为 Claude、Codex、OpenClaw、Hermes Agent 写的 skills 一般可直接在 Minis 里跑"。
元数据注入的实现(Agent/Session/SkillStore.swift:1257-1359)在技能数量超限时会做三级优先挑选(内置优先 → 7 天内新建/修改 → 按使用频率),并且诚实地告诉模型"还有 N 个没列出来,自己去 ls /var/minis/skills/":
var xml = "<available_skills>\n"for skill in selected { xml += " <skill>\n" xml += " <name>\(escapedName)</name>\n" xml += " <description>\(escapedDesc)</description>\n" // 截断到 200 字符 xml += " <path>/var/minis/skills/\(skill.id)/SKILL.md</path>\n" xml += " </skill>\n"}xml += "</available_skills>"fragment += "Reusable instruction sets stored at /var/minis/skills/<name>/SKILL.md. " + "Read the SKILL.md file to load full instructions before using a skill.\n\n"...if hasMore { fragment += "\n\n\(omitted.count) more skills not shown above: \(undisclosedNames). " + "List /var/minis/skills/ or grep to search all."}存储设计也有讲究:SKILL.md 文件在磁盘保持原样(与官方格式兼容,shell 里也能改),元数据单独存 skills.db(裸 SQLite3,含 session_skill_overrides 表做每会话开关);frontmatter 解析还容忍"用户粘贴时丢了开头 ---"的残缺文件。
7.2 记忆:就是 Markdown 文件
持久记忆没有向量数据库,没有任何花哨的东西——就是 /var/minis/memory/ 下的 Markdown 文件:GLOBAL.md(用户维护的只读全局记忆)+ YYYY-MM-DD.md 每日日志。注入策略是 GLOBAL.md 全文 + 最近 3 天有内容的日志(各取前 200 行,最多回溯 30 天)。写入时以时间戳注释开头前插到当天文件,同时登记进 iSH 的 meta.db 让 shell 侧可见、打 dirty 标记给 iCloud 同步:
// memory_write:条目以时间戳注释开头,前插到当天文件let entry = "<!-- \(timestamp) -->\n\(content)\n\n"let newContent = entry + existingtry writeData.write(to: fileURL)ensureFakefsMetadata(for: linuxPath, isDirectory: false) // 注册进 iSH meta.db,shell 可见检索(memory_get)是按时间戳切成条目后逐条打分:0.5 × 关键词命中率 + 0.5 × 归一化时间新近度,输出上限 60 条或 30KB。memoryEnabled 会话级开关同时门控工具注册(直接不放这两个工具定义)和 prompt 注入,执行侧还有 defense-in-depth 二次拒绝——能力收敛靠"工具定义不下发"而非"执行时拒绝",这是很正的安全工程姿势。
7.3 minis:// 的双义设计
minis:// 在 OpenMinis 里身兼两职:一是会话资源协议(minis://workspace/report.csv 在 agent 工具、shell、Markdown 渲染三处解析到同一个宿主文件,URL 本身不含 session id——永远只解析"当前会话",从协议层面消灭跨会话引用);二是应用深链(minis://settings/...、minis://open_terminal?init_command=、minis://sessions/<id> 等,与 Android 端路由表对齐)。深链解析有个细节很贴心:未知的 settings 路径会降级到设置首页而不是报错——因为 LLM 生成的链接可能有幻觉,"不能把用户带丢"。
八、三方依赖全景与构建链
8.1 依赖分层
OpenMinis 的依赖可以分成三层,license 策略也由此决定:

沙箱层(GPL,是全仓 GPLv3 的根因):
feature-arm64 | LICENSE.IOS) | ||
原生卸载/媒体层:
--enable-gpl) | ffmpeg 命令的宿主进程内执行 | ||
--enable-libmp3lame 链进 FFmpeg | |||
应用层(iOS SPM,只有 4 个直接依赖):
| exactVersion 2.2.0 | |||
应用层(Android Gradle,无 version catalog,版本写死在 build.gradle.kts):Compose BOM 2025.09.00、Room 2.6.1、OkHttp + okhttp-sse 4.12.0、kotlinx-serialization 1.7.3、kotlinx-coroutines 1.9.0、Coil 2.7.0、mikepenz multiplatform-markdown-renderer 0.33.0、androidx.security-crypto 1.1.0-alpha06(EncryptedSharedPreferences 存 API key)、Shizuku 13.1.5、ACRA 5.12.0(本地崩溃报告,无网络 sender)、Reorderable 2.4.0。
一个值得注意的选型哲学:能不依赖就不依赖。OpenAI/Gemini/OpenRouter/xAI/Kimi 全部手写 URLSession/OkHttp SSE 解析(Android 明明依赖了 okhttp-sse 却不用它的 EventSource,因为要按行容错、兼容厂商私有事件、精确映射错误);SQLite 全部裸 C API 直调,不用 GRDB/Room 之外的封装(iOS 侧连 Room 等价物都没有,ChatStore 6211 行手写 sqlite3_prepare_v2);rootfs 解压自写 ZIP/tar 解析器而不引 ZIPFoundation。
8.2 SwiftAnthropic 的"传输层打补丁"用法
SwiftAnthropic 是唯一被精确锁版(exactVersion 2.2.0)的 SPM 依赖,原因是 OpenMinis 对它的用法远超普通调用:三种凭证对应三个自写 HTTPClient 实现注入 SDK(Providers/Anthropic/AnthropicProvider.swift:130-184):
// API Key 通道self.service = AnthropicServiceFactory.service( apiKey: apiKey, basePath: resolvedBase, betaHeaders: nil, httpClient: EagerStreamingHTTPClient(customUserAgent: customUserAgent))// OAuth (Claude Code) 通道self.service = AnthropicServiceFactory.service( apiKey: "oauth-placeholder", basePath: resolvedBase, betaHeaders: ["oauth-2025-04-20"], httpClient: OAuthHTTPClient(tokenProvider: oauthTokenProvider))SDK 不支持的能力,全靠 RequestBodyPatcher 在 URLProtocol 层改 JSON 请求体注入:tools 的 cache_control、eager input streaming、tool_result 内嵌图片、扩展 cache TTL、新旧两版 thinking 配置、给 Anthropic 兼容代理回显未签名 thinking 块……这是"SDK 不够用就在传输层打补丁"的典型工程,锁精确版本就是为了保证 patch 的 JSON 结构假设不被 SDK 升级破坏。
8.3 构建链
iOS 构建有严格顺序(FFmpeg 链 LAME,顺序错了 MP3 支持静默消失):
./deps/build_lame.sh && ./deps/build_ffmpeg.sh # → libmp3lame.a → *.framework./deps/build_ish.sh && ./deps/prepare_alpine_rootfs.sh # → *.a → alpine-rootfs.zipopen src/ios/Minis.xcodeprojAndroid:
./deps/build_proot.sh && ./scripts/prepare_android_sandbox.shcd src/android && ./gradlew :app:assembleDebugAlpine rootfs 的定制刻意保持"纯净":不预装任何 apk 包,定制全部落在配置文件(/etc/profile 的 PS1/别名、/etc/apk/repositories 锁 v3.21 源、/etc/passwd 改 root shell),运行时按需 apk add。双端还有一份逐字节相同的 default_mount/ 出厂覆盖层,里面最有意思的是 minis-open:用 OSC 1337 终端转义序列做沙箱→宿主的带外通信,把 Linux 生态的 xdg-open https://… 习惯调用转成 App 内预览——xdg-open、x-www-browser、sensible-browser、gnome-open、kde-open 六个名字全是它的别名,覆盖 Linux 世界所有"打开浏览器"入口:
# Protocol: ESC ] 1337 ; MinisOpenURL = <url> BELemit_marker() { printf '%s]1337;MinisOpenURL=%s%s\n' "$ESC" "$1" "$BEL"}九、踩坑集锦:注释里的实战编年史
读 OpenMinis 的源码注释本身就是一种享受——几乎每个 workaround 都附着事故日期、症状描述和任务号(T-xxx/GH#xxx)。摘几个最有代表性的:
- 模拟器里跑 Node
:V8 的 JIT 在 guest 自我修改代码时超出 Asbestos 能力,必须注入 --jitless --no-lazy --no-expose-wasm --max-old-space-size=512,还要LD_PRELOAD=/lib/zero_free.so修 V8 对free()清零语义的假设(ISHKernel.m:697-716)。同类还有GODEBUG=asyncpreemptoff=1(Go 的异步抢占信号干扰模拟器)、PYTHONMALLOC=malloc、GOMAXPROCS=2。 - musl 的 TZ 解析器
不认带 +/- 的缩写( GMT+8会被误解析),所以双端都自造 POSIX 格式TZ=LCL+8。 - heredoc 缺尾换行
:busybox ash 对 …\nEOF结尾缺换行的命令 100% 报unexpected end of file,executor 层强制补\n。 - iOS 26 TextKit1 重入崩溃
:markdown 表格流式渲染触发 NSTextContainer setSize:重入,被 watchdog 以 0x8BADF00D 杀进程——解法是在 App 启动时 swizzle 掉它(MinisApp.swift:106-146的NSTextContainerSetSizeGuard.install()),顺便还有 AttributeQueryRecorder 供 HangDetector dump、Bundle 语言 swizzle 实现免重启换语言、LAContext 预热(首次冷启 XPC 约 500ms)等一整套"防御性预热"。 - bashism 规则库
: src/shared/bashism/bashism_rules.json是双端唯一事实源,21 条规则按危害分级——最阴险的是 S 级(silent-wrong):(( x > y ))在 busybox ash 里会被解析成嵌套子 shell、x+=1变成"命令未找到"但脚本照常跑。每条规则都在真机 Alpine 3.21.0 / BusyBox 1.37.0 上验证过,且模式必须待在 ICU/java.util.regex 的公共子集内、禁止嵌套量词(防 ReDoS)。命中 T1 级(显式 bash 语法)时按需apk add bash,配哨兵退出码 119 的自愈逻辑。 - 系统 prompt 也是工程产物
:prompt 里固化着大量 iSH 实战经验——"BusyBox ash 无 globstar"、"PyPI 缺 musllinux wheel 要用 apk add py3-*"、"matplotlib 必须use('Agg')"、"后台进程必须重定向防 SIGPIPE";"执行纪律"段落的注释直接引用 2026-07-17 的设备日志事故(agent 承诺"我每 1-2 分钟检查一次"然后沉默了——turn 结束后没有任何东西在跑)。 - file_read 的 80KB 硬上限
(Android FileReadTool.kt:45-46):注释记录了一次真实 hang——820KB 的 base64 图片内联进 tool_result 后,Compose 的StaticLayout/LineBreaker卡了 43 秒。 - WebApp 快捷方式全部存沙箱相对路径
( WebAppPathResolver.swift:44-70):因为 iOS 重装会轮换容器 UUID,绝对路径全得报废。
十、总结
通读完 OpenMinis 双端约 34 万行代码,我对它的评价可以浓缩成三句话:
第一,它的技术护城河恰恰在 README 说"不重要"的地方。 把 iSH 改造成 ARM64 进程内嵌内核(JIT 崩溃恢复、fd 表直连、fakefs meta.db、fs_context 路由)、把 PRoot 伪装成 libproot.so、把 FFmpeg 驯化成可重入库函数(三层 patch + argv 重写 + 线程局部 stdio)——每一处都是对平台限制的正面硬刚,没有一处能靠"调 API"实现。
第二,Agent 运行时的成熟度是被真实 bug 喂出来的。 孤儿 tool_use 修复、tool_call_id 去重、ToolJsonRepair、四级熔断、静默断流检测、组内 fallback、分级节流梯子……这些防御性代码的存在本身就说明它经历了大规模真实使用。双端"Mirrors iOS 行号"的人肉 parity 文化,也为"如何维护双端行为一致"提供了一个罕见的实战样本。
第三,依赖策略极其克制。 沙箱层用 GPL 重器(iSH/PRoot)并因此全仓 GPLv3,应用层却只用 4 个 SPM 包 + 一组标准 AndroidX/OkHttp 组件,能手写就不引入——手写 SSE、裸调 SQLite、自写 ZIP/tar 解析。这种"关键处重投入、边缘处零依赖"的搭配,让 34 万行的代码库保持了惊人的可构建性(BUILDING.md 声称首次构建 30-60 分钟全脚本化)。
如果你想研究"移动端 AI Agent 到底能做成什么样",这个仓库几乎是当下最好的教材:它把 Agent loop、Linux 沙箱、系统能力集成、双端对齐、流式性能优化这五个难题,在同一个产品里全部给出了解法。
本文基于 2026 年 8 月检出的 OpenMinis 公开镜像源码(Android 侧 versionName 0.20-preview)撰写,所有代码片段均标注了仓库内文件路径与行号,可对照原文核实。文中架构图为笔者根据源码绘制。
这篇文章是我让 Kimi k3 去深度分析源码写出来的。我也想把这篇文章分享给大家,大家一起共同学习这么优秀的开源项目。Kimi K3 真能写~
夜雨聆风