Mova 是我自己造的一个 AI 辅助 2D 游戏角色动画工具。你在聊天面板里用自然语言描述想要的动画就行,比如「做一个 16-bit 像素风的蓝色史莱姆,idle 四帧」。应用接下来会通过 LLM 解析意图,调用图像生成 API 出角色帧,再走一遍品红背景去除和切帧,登记到项目里,最后导出 Sprite Sheet。桌面壳我用的是 Tauri v2,前端 React 19 加 Konva,后端 Rust。代码在 git.ruochongliang.top/lrc/Mova。

这篇不讲用法,我想复盘三件事。一是 AgentOrchestrator 这套「浏览器侧 provider + skill + tool runner」的多轮工具调用模型。我要讲它怎么设计,也讲我为什么没把它做成更时髦的多 Agent / Plan-Execute 架构。二是 Tauri 在 Linux 上绑死了 WebKitGTK 这个根本性的平台代价,它不是某个 bug,是框架选型带进来的结构性劣势。三是我要坦白记录这个项目欠下的性能债,这部分我没有粉饰,因为承认问题是修它的前提。如果你也在做 AI 辅助创作工具,或者正在评估「Tauri + React + Konva」这套组合能不能扛住桌面级交互,下面这些判断应该能直接用得上。

技术栈与三层职责切分:AI 编排为什么全放在前端

先说技术选型,因为它直接决定了后面所有的取舍边界。这个项目的三层职责切分是这样的:

层技术职责边界
桌面壳Tauri v2(Rust 2021)窗口、文件 I/O、图像处理、Git 集成、导出编码
前端React 19 + TypeScript 6 + Vite + Tailwind 4UI、画布编排、状态管理、AI agent 编排
画布Konva / react-konva像素图绘制、精灵预览、时间轴帧渲染
状态Zustand 57 个 store(project / animation / chat / settings / drawing / git / file-tree)

职责切分的核心判断是:AI 编排全放在浏览器侧,Rust 只做重活和脏活。Rust 后端注册了 26 个 IPC command 加 2 个 plugin(dialog、fs),但这些 command 全是无状态的工具。它们管的是项目读写、图片 resize / split / 去背景、sprite sheet 合成、GIF 编码、Godot 帧导出、文件树扫描、Git 操作。没有一行 AI 逻辑下沉到 Rust,LLM 调用、工具调用循环、skill 路由都在 TypeScript 里。

这个划分不是技术洁癖,是务实判断。Rust 侧做 AI 有它的好处,能用更重的依赖,也能避开 WebView 的内存压力。坏处是调试反馈循环慢一个数量级:改一行 prompt 要重编 Rust、重启应用,而前端改完 Vite HMR 秒级生效。对一个 prompt 和 skill 还在快速迭代的原型阶段来说,把 agent 内核放在能热重载的一侧更划算。真正吃 CPU 的图像处理和导出编码,我留给 Rust。

┌─────────────────────────────────────────────────────┐
│  WebView (React 19)                                  │
│  ┌───────────┐  ┌───────────┐  ┌──────────────────┐ │
│  │  Canvas   │  │  FileTree │  │  ChatPanel       │ │
│  │  (Konva)  │  │  GitPanel │  │   └ AgentOrch.   │ │
│  └─────┬─────┘  └─────┬─────┘  └────────┬─────────┘ │
│        │              │                 │            │
│        └──────────────┴─────────────────┘            │
│                       │ invoke()                     │
├───────────────────────┼─────────────────────────────┤
│  Rust (Tauri v2)      ▼                              │
│  26 commands: project / image / export / map /       │
│               file_tree / git / file_ops / watcher   │
│  deps: image 0.25 · gif 0.13 · gix 0.84 · notify 7  │
└─────────────────────────────────────────────────────┘

还有一个 Rust 依赖上的取舍值得记一笔:Git 集成我选了 gix(gitoxide),没用 git2(libgit2 绑定)。Cargo.toml 里我留了一段注释,把决策理由钉死。gix 是纯 Rust,没有 C / libgit2 依赖,跨编译简单,二进制体积也更小(约 2-3MB 对 5MB),对 MVP 需要的 init / status / add / commit / log 接口覆盖得够用。代价是 gix 没 git2 那么稳定打磨,但为这个用例不值得背 libgit2 的 cmake 构建复杂度。这是一类反复出现的取舍:纯 Rust 依赖优先于更成熟但带 C 依赖的方案,除非成熟度差距大到影响功能。

AgentOrchestrator:多轮工具调用,我为什么只用一个 while 循环

AI 内核是整个项目最有意思的部分,也是我最想讲清楚的设计。它的架构很轻,类名叫 AgentOrchestrator,本质是「单 Agent + Skill + Tool」的编排器,跑在浏览器侧。

// src/services/ai/agent-orchestrator.ts(精简)
export class AgentOrchestrator {
  private provider: LLMProvider | null = null
  private activeSkill: Skill | null = null
  private toolRunner: ToolRunner
  private conversationHistory: LLMMessage[] = []
  private maxToolRounds = 5
 
  async processUserMessage(userMessage, callbacks) {
    const messages = [
      { role: "system", content: this.activeSkill.systemPrompt
          + "\n\n" + this.activeSkill.buildContext() },
      ...this.conversationHistory,
      { role: "user", content: userMessage },
    ]
    while (roundsRemaining > 0) {
      // ① 流式调 LLM,收集文本 + tool_call
      for await (const chunk of this.provider.streamChat(...)) { ... }
      // ② 没工具调用就结束
      if (currentToolCalls.length === 0) break
      // ③ 执行工具,结果塞回 messages 进入下一轮
      const results = await this.toolRunner.executeToolCalls(currentToolCalls)
      messages.push(...results.map(toToolMessage))
      roundsRemaining--
    }
  }
}

核心是一个 while 循环,最多 5 轮工具调用。每一轮都做三件事。先把 system prompt、skill 上下文、完整历史、用户消息拼成请求,流式读 LLM 输出,同时收集 tool_call chunk。如果这一轮有工具调用,就执行它们,把结果作为 role: "tool" 消息塞回 messages 数组,进入下一轮。如果某轮 LLM 没再要工具,循环就 break,最终文本作为 assistant 消息回传。maxToolRounds = 5 是个硬上限。跑满 5 轮还没收敛,我就当错误处理,回滚到本轮开始前的历史快照。

Skill 是 prompt、工具集、上下文构建器三样拼起来的,skill 不是一个独立 Agent。目前四个 skill:general(项目查询 / 建议)、character-creation(生成角色图并登记)、sprite-generation(精灵表 + 去背景 + 切帧 + 登记)、map-generation(底图 + 道具 + 碰撞 + 预览)。每个 skill 自带 system prompt。比如 sprite skill 的 prompt 里就写死了几条生产纪律:背景必须 100% 纯品红 ff00ff;角色资产禁止用 1xN 单行条带,会导致位移漂移;每次 generate_sprite_sheet 之后必须先 process_sprite 再 extract。

工具调用链覆盖了游戏美术的核心闭环:generate_image / create_character / generate_sprite_sheet / process_sprite / extract_animation_frames / generate_map_base / generate_props / extract_props / compose_preview / create_collision_data。这些工具最终都通过 Tauri invoke 落到 Rust,做的是保存图片、去背景、切帧、合成地图预览这些事。所以 agent 不是在「聊天」,是在「干活」,每一步都有文件系统层面的产出。

意图路由用的是关键词匹配:SkillRegistry.getSkillForIntent 把用户消息 toLowerCase 之后做 substring 匹配。「角色 / character / 像素角色」路由到 character-creation,「sprite / 精灵 / pixel art」路由到 sprite-generation。这个方案便宜稳定,但有典型误判。agent-kernel-strategy 报告里点了一个真实例子。用户说「我不要地图,只想让角色拿着 map 道具」,这句话会被关键词「map」错误路由到 map-generation。

多 Agent / Plan-Execute 看起来更先进,我为什么不做

这是最常被追问的问题。research 报告里我自己列过当前内核和「先进 Agent」的差距。缺显式 Planning,工具调用串行,没有长期记忆,意图识别太简单,没有结构化 Workbench 步骤,也没有权限确认。这些我都知道,但我主动选择不做,理由是分阶段的性价比。

升级方向代价为什么暂时不做
多 Agent / 子智能体编排复杂度陡增,调试和成本翻倍当前工具闭环(角色→精灵→地图)线性依赖,单 Agent 串行已覆盖
显式 Plan-Execute要新增 plan 数据结构、步骤状态机、失败恢复MVP 阶段 skill prompt 里已写死工作流,够用
并行工具调用需处理依赖图、结果汇聚、API 成本失控生成类工具天然有依赖(先出图才能切帧),并行收益小
长期记忆 / 项目知识索引要建资源索引、偏好存储、检索原型阶段对话短,全量历史塞进 prompt 还没爆

maxToolRounds = 5 这个上限,就是上面这些判断的具象化。它承认我不打算让 agent 跑无限长链,把复杂任务挡在门外,换可预测性。research 报告里的建议路线是 P0 先把现有能力包装成可展示的 Workbench Step(Queued / Running / Completed / Error),P1 再做模板化 Plan-Execute。再往后是 P2 做 sprite / 地图 QA,P3 才引入有限并行。我的判断是:先把单 Agent 的可观察性补齐,比堆架构更值钱。

Important

AI 辅助工具的 agent 内核,第一版应该追求「可观察、可中断、可回滚」,而不是「自主、长链、多智能体」。一个能在 5 轮内收敛、失败时干净回滚的简单循环,比一个能跑 50 步但中途不可见的大架构更可信。

设计理念:AI 生成和人工精修,我为什么放进同一个工作流

Mova 不是「一键生成」工具,这是个刻意的产品判断。现在市面上的 AI 画图工具,多数停在「输入 prompt → 出一张图 → 满意就用不满意重抽」这一步。但游戏美术生产的现实是:生成只是起点,精修才是主体。一张精灵表出来,边缘可能碰格,帧大小可能漂移,背景也可能不是纯品红。这些都要人进去修。

所以画布侧我配了一整套像素级编辑能力:笔刷、橡皮、填充、形状工具、撤销重做、时间轴帧管理。AI 生成的精灵帧落盘之后,你可以在画布里直接擦掉误生成的元素、对齐边缘、补帧。我是把「AI 生成 + 人工精修」焊在同一个工作流里,而不是切成两个工具。

这个姿态决定了 agent 的工具怎么设计。process_sprite 返回的 QC 数据里有 edge_touch、帧尺寸、grade(pass / pass_with_warnings / fail)字段。agent 的 sprite skill prompt 里写明了「grade 为 fail 时必须重生成,不许 extract」。工具不只产出文件,还产出可被 agent 自检的结构化质量信号,这样 agent 才有机会在交付前自查,而不是把烂结果甩给你。当然,现状离真正的「反思型 agent」还远。QC 字段目前更多是给 prompt 当约束,agent 是否真会据此重试,依赖模型遵循指令的稳定性。

项目存储我用的是目录式,不是数据库。结构上就是一个 project.json,加上 characters/ / sheets/ / frames/ / maps/ / props/ 子目录。这么做是为了 git-friendly,你可以对整个项目目录做版本控制,Rust 侧用 gix 提供 init / status / add / commit / log。选目录而不是 SQLite,是因为动画素材的本单位就是文件,也就是一张张 PNG。文件系统天然就是它的索引,强行加一层 DB,反而把「用 Git 管版本」这件事割裂了。

平台债:Linux 上的 WebKitGTK 把体验上限锁死了

前面讲的取舍都在我能控制的范围内。现在这一个不是。它是我在 Linux 上控制不了、却决定体验上限的根因,也就是 Tauri 的 webview 内核。

Tauri 的卖点是用系统自带 webview,不捆绑 Chromium,换来小体积。代价是三个平台三种内核,性能天差地别:

平台Tauri 用的内核渲染性能
WindowsWebView2(Chromium 内核)第一梯队
macOSWKWebView(Apple WebKit)第一梯队
LinuxWebKitGTK(libwebkit2gtk-4.1)明显落后

WebKitGTK 是 GNOME 阵营维护的 WebKit 实现。问题在于它既不是 Chromium,也不是 Apple 亲自维护的 WKWebView。它是一个资源相对单薄的开源项目,长期在 GPU 加速、CSS 复合层、Canvas 2D 性能上落后于 Chromium 系。这不是某个版本的偶发 bug,而是这个项目多年来的结构性处境。Tauri 社区、HN 讨论、Reddit 的 r/tauri 上,这一点被反复批评过。Tauri 在 Linux 上的体验,本质上被 WebKitGTK 的天花板焊死了。

这对 Mova 是直接命中。Mova 的画布是 Konva 驱动的。大量 <Layer> 重绘、Konva.Shape 节点增删、视口变换、像素级笔刷渲染,全是 Canvas 2D 和 DOM 复合层上的高频操作。这正好是 WebKitGTK 最弱的场景。我在 Linux 上跑的时候,画布交互的帧率明显不如在 Windows 上用 WebView2 跑同一个 build。同样的代码,换个内核,体感差一档。

lib.rs 里那段 WEBKIT_DISABLE_DMABUF_RENDERER=1,只是在这个大背景下打的一个补丁。WebKitGTK 的 DMABuf renderer 在某些 Mesa / 驱动组合下会让进程直接 abort,所以我默认关掉它换稳定。但这是用稳定性换性能,本来就慢,现在还要关掉最快的渲染路径之一。配套的 tauri:dev:linux-fast / linux-compat / linux-x11 / linux-force-compositing 四种启动模式,本质都是在「别崩」和「别太卡」之间找平衡点。roadmap 把「Linux 是性能优化主战场」写成方针,背后的潜台词就是:内核本身就弱,只能在应用层拼命补。

Important

选 Tauri 之前要诚实回答一个问题:你的应用是不是渲染重负载? 如果是 Canvas / 动画 / 大量 DOM 操作,且 Linux 是主要目标平台,WebKitGTK 的性能天花板会持续咬你。Tauri 的体积优势在这个权衡下未必划算。Electron 虽然大,但它在所有平台上都是 Chromium,性能一致且可预测。Mova 选 Tauri,是因为我对 Rust 侧的图像处理依赖更在意(纯 Rust 的 gix / image)。原型阶段我一个人开发,以 Linux 单平台测试为主,这些能忍。但如果这是个面向大量 Linux 用户、画布交互密集的产品,我会重新评估。

诚实复盘:六笔没还上的性能债

这一节是全文的重点,也是我写得最不轻松的部分。Mova 现在能 pnpm tauri dev 跑起来。lint / typecheck / test / build / tauri build 全绿,有 56 个测试文件,Linux 上能打包成 .deb / .rpm / AppImage,看起来像个「中高成熟度」的原型。但能跑和跑得流畅是两回事。以下是 research/mova-local-assessment.md 里我自己识别、且承认还没修的性能债。

债一:文件树全量递归扫描

Rust 的 list_project_files 默认递归深度 10、上限 50,read_entries 对每个目录同步递归读取 metadata。隐藏文件和 symlink 它跳过了,这点安全做得到位。但它没有忽略 .git / node_modules / dist / target 这类重目录。前端拿到整棵树后,搜索时还要再递归 filter 一遍。

后果是:项目一旦大起来,或者目录里混进了 node_modules,文件树首次加载和每次刷新都卡。这本来是最容易做出前后对比演示效果的优化:忽略重目录,懒加载子目录,UI 再显示「过大已截断」。但我还没做。说实话,这债拖到现在,主要是因为我测试用的都是小项目,没被它真正咬过。

债二:canvas-area.tsx 是个 1432 行的巨组件

src/components/canvas/canvas-area.tsx 现在 1432 行(research 报告里写的是「超千行」,现在更胖了)。图片加载、棋盘格背景、视口变换(缩放 / 平移)、绘图工具、时间轴帧预览、快捷键、保存状态,全塞在一个组件里。onMouseMove 直接驱动绘图工具,多个 Stage / Layer 绑定同一套事件。

这不是审美问题,是实打实的重渲染风险。一个 1400 行的组件,内部任何 state 变化都可能触发大范围重渲染,而画布交互是最高频的,鼠标移动每秒几十次。research 报告给的改造路径很克制:不必重写渲染,只拆出 useCanvasImageCache / useDrawingHistory / useCanvasViewport 三个 hook。把状态和逻辑从组件里抽出来,耦合自然就降了。这个我也还没动,因为拆 hook 的风险点在事件闭包里大量的 ref,动一处可能引入难复现的笔触错乱。

债三:图片缓存无上限 LRU

画布侧有个模块级 imageCache 和 imageLoadCache:

// src/components/canvas/canvas-area.tsx
const imageCache = new Map<string, HTMLImageElement>()
const imageLoadCache = new Map<string, Promise<HTMLImageElement>>()

两个都是只增不减,没有 LRU 淘汰,也没有总像素上限。时间轴还会 preloadImages 预加载所有帧。长时间编辑一个有上百张帧的项目,内存就会持续膨胀而且不释放。模块级 Map 的危险在于它活在组件生命周期之外,切项目都不会清。修法是给缓存加上限(按数量或总像素),并在项目切换时主动 clear()。又是知道怎么修但没修的一条。

债四:绘图历史存 Konva 节点数组

撤销栈 drawingUndoStackRef 深度 50,看似有限。但每一步存的是 Konva.Shape | Konva.Group 节点引用数组,不是轻量 diff,也不是 bitmap patch。复杂笔刷操作一步可能引用几十个节点,大图上 50 步历史的内存和重绘成本都不低。理想的存法是 bitmap patch(只存变化区域)或命令模式(存操作 + 逆操作),但当前实现图省事,直接存了节点引用。这是个典型的「先让它工作,再让它工作得好」的债。

债五:聊天历史无 token 裁剪

AgentOrchestrator 每轮都会把整个 conversationHistory 拼进请求,没有 token 预算、没有摘要、没有滑窗。聊天用 localStorage 持久化(zustand persist),所以历史只会越来越长。

后果分两层。UI 层,长对话会变慢,每条消息都要重渲染。agent 层,长对话会悄悄超上下文窗口然后报错。你看到的只有一句「Maximum context length exceeded」,完全不知道是历史太长。修法是在 orchestrator 里加「最近 N 轮 + 早期摘要」,或者至少加个 token 估算的硬截断。这条优先级我排在很高,因为它是用户最容易踩到的坑。

债六:工具调用串行执行

ToolRunner.executeToolCalls 是 for 循环逐个 await:

for (const tc of toolCalls) {
  const result = await this.executeToolCall(tc.name, parsedArgs)
  results.push({ id: tc.id, name: tc.name, arguments: parsedArgs, result })
}

MVP 阶段这么做是合理的,Mova 的工具多数有依赖,先生成图才能切帧,并行没收益。但遇到模型一轮里抛出多个互不依赖的调用,串行就吃亏了。比如同时生成三个候选 prompt,或者同时 QA 检查多个资源,本来可以并行,现在延迟全加在一起。agent-kernel-strategy 报告里把「有限并行」列为 P3。它的建议是优先并行纯文本 / 检查类任务,少并行图像生成,避免 API 成本失控。这条债的成本最低、收益最直接,但同样没动。

Important

这些债的共同特征是:每一项单独看都不致命,项目能跑能演示;但叠在一起,就构成了**「规模一上来就崩」的悬崖**。我把它们列出来,不是想自我表扬「我有多诚实」。我想说的是一个更普遍的判断:原型阶段的性能债不可怕,可怕的是不建立一份「已知债务清单」。不然每次加功能,你都不知道悬崖在哪。Mova 现在这份清单,就是它偿还路线图的输入。

Rust IPC 层:Rust 做工具,TypeScript 做编排

Rust 侧的设计也值得记一笔。26 个 command 按领域分包:project / image / export / map_commands / file_tree / git / file_ops / file_watcher,外加 chat(对话持久化)。每个 command 都是无状态的 #[command] async fn。参数和返回值都实现 Serialize,靠 serde 在 IPC 边界做 camelCase 转换。

这里有一个明确的取舍:我不把 command 做成有状态的「agent 后端」。没有任何 Rust command 知道当前在跑哪个 skill,也不知道对话进行到第几轮,这些状态全在前端。Rust 只暴露原子能力(读文件、切帧、编码 GIF),前端负责编排。好处是 Rust 侧极简、可测。每个 command 都能用 block_on 直接跑单测,file_tree.rs 里就有一堆 symlink 安全性测试。坏处是复杂的 agent 逻辑享受不到 Rust 的性能和内存安全。分层很清楚:Rust 做工具,TypeScript 做编排。

另一个细节是 Linux 兼容性的启动模式矩阵:tauri:dev:linux-fast / linux-compat / linux-x11 / linux-force-compositing 四种模式,对应 WebKitGTK 在不同显示服务器和合成器下的不同脾气。这是上一节那个平台债在工程层的具象,因为内核不可控,只能在启动参数上多打几个挡位。

能迁移到别的项目上的几条判断

写到最后,提炼几条我认为能从 Mova 这个项目里迁移到其他 AI 辅助桌面工具的判断。

  1. agent 内核放在能热重载的那一侧。Rust 编译慢,prompt 迭代快,把 agent 编排放前端、重活下沉 Rust,是这个阶段的最优分工。等 skill 和 prompt 稳定后再把编排下沉,顺序不能反。
  2. 工具要产出可被自检的结构化质量信号,而不只是文件。process_sprite 返回 edge_touch / grade 让 agent 有机会自查,这是把「干活」和「验收」焊在同一层的尝试。纯产出文件的工具,agent 只能盲信结果。
  3. 单 Agent 加硬轮次上限,好过无约束的多 Agent。maxToolRounds = 5 把不可控性挡在门外。原型阶段的可信度比自主性重要得多。
  4. AI 生成与人工精修必须同工作流。一键生成工具的天花板是「用户满意重抽率」。创作工具的天花板是「用户能精确改到想要的样子」。把画布像素编辑和 AI 生成放一个应用里,是为了不逼迫你在两个工具间反复横跳。
  5. 资产的本单位是文件时,目录式存储优于数据库。加 SQLite 看起来「专业」,但割裂了 Git 版本控制这条已经存在的能力。能用文件系统当索引就别多加一层。
  6. 也是最重要的一条:建立已知债务清单,比假装没有债更值钱。 Mova 的性能债我没全还,但每一项都写进了 research 报告,标了影响和修法。原型阶段的工程能力,很多时候体现在你有多清楚自己欠了什么,而不是你声称自己有多干净。
  7. 分清「我能控制的债」和「平台塞给我的债」。前六条债都是我自己代码写出来的,理论上都能还。但 WebKitGTK 那条不是,它是 Tauri 这个框架在 Linux 上的结构性代价,我再怎么优化应用层也冲不破内核天花板。选框架时要诚实评估这条:框架给你便利的同时,也把它底下的内核债转嫁给了你,而用户最终只会把这个卡顿算在你头上。

Mova 现在版本号 0.1.1,是个能跑、有测试、能打包,但性能悬崖清楚可见的原型。它不是成品,我也不打算把它包装成成品。如果你也在做类似的 AI 辅助创作工具,希望这些取舍和债务记录能帮你少踩几个坑。或者至少,让你在踩坑时知道这坑别人也踩过。