Skip to content

openhanako 架构学习:规划与步骤

对象是 openhanako / HanaAgent,一个开源的本地 AI Agent 桌面应用(Electron 42 + Hono 服务 + React 19,Agent 运行时基于 Pi SDK,约 9000 文件)。

这篇记录学习路线本身:分几层、每层读什么、读完要能回答什么、最后产出什么。

为什么选它

同类开源项目大多停在「聊天框 + 工具调用」。这个项目往前多走了几步,把一些真实工程问题都落地了:

  • 记忆不是简单塞进 prompt,是一套分层 LLM 编译流水线
  • 权限不只是确认弹窗,是应用层 PathGuard + 操作系统级沙盒双层
  • 扩展不只是加工具,是有协议文档和两级权限模型的插件系统
  • 接入不只是桌面端,同一个服务同时供给 CLI、移动 PWA、局域网第二桌面端和四个聊天平台

同时它有非常典型的技术债形态,适合研究「功能堆得比抽象快」的后果。学一个只有优点的项目学不到判断力。

四层学习路线

不按目录顺序读,按「一条消息的生命周期」读。每层配验收问题,答不上就是没读懂。

L1 消息主链

— 应用入口 → 服务层 → 引擎 facade → 会话协调器 → Agent → SDK 适配层

验收问题

  • 一条消息从 WebSocket 进来到落盘,经过哪几个对象?
  • 中断(abort)在哪一层被处理?
  • 桌面端、CLI、手机端走的是同一条路径还是各有分支?

结论 — 服务进程是唯一真相源,所有前端都是它的 WebSocket 客户端,没有特权路径。这是移动端和局域网第二桌面端能低成本做出来的原因,代价是所有前端共享同一个权限身份。

L2 记忆系统

— 记忆编译模块(顶部注释已经把整个设计写全了,是全仓性价比最高的 20 行)

验收问题

  • 分层传送带每一层的输入输出是什么?
  • 为什么「周」这一层不调 LLM?
  • 指纹缓存防的是什么开销?
  • 最终产物为什么要设 token 硬上限?

结论 — 传送带结构是:会话摘要 → 日编译 → 周装配 → 滚出窗口的折叠进长期 → 最终装配成一份进 prompt 的记忆文件。三个决策值得抄:周层零 LLM(纯文件拼接,省钱且可重放)、每个产物配指纹(源没变就跳过重编译)、最终产物有 token 硬预算(因为它每轮都进 prompt,真正的约束不是「能记多少」而是「每轮能带多少」)。

L3 安全与沙盒

— 沙盒目录(22 个文件,三平台)+ 权限模块 + 能力授权策略

验收问题

  • 应用层路径守卫和操作系统沙盒是什么关系,各自防什么?
  • Windows 为什么只做写隔离不做读隔离?
  • 能力授权的判定链,实际生产路径上真的会执行吗?

结论 — 最后一问的答案是「不会」。判定链第一步就对本地所有者直接放行,而运行时上下文又把信任状态硬编码成「本地」,于是整套授权机制在单机场景下从未真正执行过。这不是 bug,是尚未接线的半成品——数据层(授权记录、设备配对、挂载点能力声明)都建好了,决策层还是占位。

L4 扩展生态

— 插件协议文档 + 插件管理器 + 内置技能集 + 技能包 + 角色卡

验收问题

  • 受限 / 完全权限两级具体卡住了哪些能力?
  • 角色卡导出的白名单带什么、不带什么?
  • 社区技能的审核在哪一步发生?

三个值得抄的设计

1. Agent 就是一个自包含文件夹

人格、记忆、会话、书桌、头像全在一个目录里。角色卡导出、备份、迁移、多 Agent 并存——全都是这一个决策的直接红利。很多同类项目把这些塞进全局数据库,之后就再也拆不出来了。

通用教训:可移植性是设计出来的,不是后期加的。

2. 系统提示词按缓存分界线排序

这是全仓最值得学的一段。提示词拼接遵循「静态前缀在前、动态尾部在后」,中间有一条明确的缓存分界线:

平台 → 环境 → 用户档案 → 人格 → 行为指南
──────── 缓存分界线 ────────
记忆规则 → 置顶内容 → 记忆 → 会话开始时间

原理:KV cache 和各家的 prompt cache 都按严格前缀匹配。把几乎不变的放前面、每次都在漂的放后面,跨会话缓存命中率最大化。

分界线的划法很讲究:用户档案和人格虽然是用户数据,但只在用户主动修改时才变,属于事件驱动的稳定段,放静态区;记忆被后台编译推动、时间戳每次构建都在走,是自动漂移源,必须放动态区。

还有一个纯叙事考量:人格段放在用户档案之后,因为人格模板里有「你和用户是认识很久的人」这类引用——先建立「用户是谁」的语境,再说「你是谁、你俩什么关系」。

通用教训:往静态区插一个会变的字段,会静默摧毁全局缓存命中率,而且不会有任何报错。这类「错了也不报错」的设计,必须靠注释和纪律守。

3. 外部 SDK 的适配层纪律

Agent 运行时依赖一个外部 SDK。项目里有一个模块是唯一允许导入该 SDK 的地方,头部写着明确纪律:稳定 API 直接转出,不稳定 API 包一层适配;不接受业务对象参数、不拼配置、不做业务逻辑、不持有状态。甚至标注了「某函数在 x.y.z 版本仍未从包根导出,深路径保留,升级时必查此文件」。

通用教训:外部依赖的隔离质量,决定了将来能不能换掉它。这个模块和项目里那个八千行的会话协调器出自同一个作者——说明问题从来不是能力,而是有没有对某个边界建立同等的纪律

三个痛点

  1. 会话协调器 8419 行。一轮对话的完整状态没有外部表示,状态散在实例字段和事件发射调用里。这不是「函数太多」,而是「唯一知道全局状态的地方」,所以拆不动。
  2. 执行边界是占位常量。已有的授权机制和挂载点能力声明因此成了死代码。
  3. 事件是无类型自由结构。事件总线自己都用可选链取 type 字段;消费端漏处理不会有编译错误;已经出现同一状态变更连发三条事件的写法——因为没有唯一事实,只能对每个消费者各发一条。

三点共享一个根因:产品面跑得比内核抽象快。功能一个个堆上去都能工作,但「一轮执行是什么」始终没有被显式建模。

产出与下一步

学习阶段的产出是三份文档:一份架构导读(写给「第一次要在这个仓库做设计决策的人」),两份改进提案。

提案都遵循同一个格式:问题陈述必须带代码证据(文件行号)→ 目标与非目标 → 设计 → 分阶段迁移,每阶段可独立回滚 → 验收标准 → 风险表

其中最重要的一条方法论:每个提案都必须有一个「硬关口」阶段。比如引入统一事实模型的提案,第二阶段是「把历史数据往返转换一遍,验证首尾等价」——不通过就不许进入第三阶段。因为如果往返丢信息,说明模型本身漏了东西,这时候该补模型,而不是补特例。没有这道关口,重构就会退化成一边改一边打补丁。

下一步是把提案 01 的第一阶段做成可运行代码:纯类型定义加纯函数,零运行时接入,只有单元测试。风险最低,随时可删。

附:读陌生大项目的通用流程

这套流程是几次实践后固定下来的,近期学习计划页尾也有一份:

  1. 先看数据目录,不看代码——数据布局是设计决策的化石
  2. 按数据流读,不按目录读
  3. 每层写验收问题并自答
  4. 找作者自己标注的接缝(legacy_*、TODO、「过渡态」注释)——在这些地方做改进最正当
  5. 注释密度最高处就是设计精华——作者花力气解释的,都是他怕被改坏的
  6. 同时找一个对照项目——单看一个分不清「这是设计」还是「这是唯一做法」