iii-engine 底座:为什么只有三个原语
本章讲什么: agentmemory README 反复强调「全部跑在三个原语之上、零外部数据库」。这章解释这三个原语是什么、KV 怎么落地、为什么这个架构选择让整个项目这么紧凑。
1. 三个原语
agentmemory 不直接管线程、端口、数据库连接。它把所有东西注册到一个外部进程 iii-engine(WebSocket ws://localhost:49134),只用三个概念:
| 原语 | 是什么 | agentmemory 怎么用 |
|---|---|---|
| Worker | 一个连到引擎的进程 | registerWorker(...),整个 src/index.ts 就是一个 worker |
| Function | 引擎可调用的具名逻辑单元 | mem::observe mem::search state::get 等 250+ 个 |
| Trigger | 什么事件触发哪个函数 | HTTP 路由、durable 订阅、state 变更、cron |
README 的一句话总结(README.md:1214):「174 源文件 · ~37,800 行 · 258 函数 · 44 KV scope——全在三个原语上。没有 agentmemory plugin install。插件系统就是 iii 本身。」
2. Worker:启动即注册一切
main()(src/index.ts:160)做的第一件大事是 registerWorker(src/index.ts:195),拿到一个 sdk 句柄。之后整个文件就是一长串 registerXxxFunction(sdk, kv, ...) 调用(src/index.ts:235-333)——每个把一组相关函数挂上去。
// src/index.ts:195 —— 注册 worker(真实源码,节选)
const sdk = registerWorker(config.engineUrl, {
workerName: "agentmemory",
invocationTimeoutMs: 180000,
telemetry: { project_name: "agentmemory", language: "node", framework: "iii-sdk" },
});
worker 还有个不显然的运维细节:它把自己的 pid 写到 ~/.agentmemory/worker.pid(src/index.ts:115-123,符号 writeWorkerPidfile)。因为 worker 是被引擎 spawn 的,agentmemory stop 只杀引擎时这个 worker 可能存活并重连成「重复 worker」(#640/#474)——写 pidfile 让 stop 能把它一起收掉。
3. Function:业务逻辑的单位
每个函数是 sdk.registerFunction("mem::xxx", async (data) => {...})。约定(AGENTS.md):校验输入 → 通过 kv.get/set/list 干活 → recordAudit() 记审计 → 返回 { success, ... }。
函数之间靠 sdk.trigger({ function_id, payload }) 互相调用——这是引擎内的 RPC。比如 mem::observe 写完观察后 sdk.trigger({ function_id: "stream::set", ... }) 推流给 viewer。带 action: TriggerAction.Void() 的是「发了不等结果」的 fire-and-forget(observe.ts:159-174)。
4. KV 状态层:对 state:: 的薄封装
agentmemory 没有自己的数据库代码。所谓「state」就是引擎提供的 state::get/set/list/update/delete 五个内置函数,持久化到引擎的 StateModule(文件型 SQLite,./data/state_store.db)。
StateKV(src/state/kv.ts,符号 StateKV)就是这五个函数的一层语法糖:
// src/state/kv.ts —— KV 全靠引擎的 state:: 函数(真实源码,节选)
async get<T>(scope: string, key: string): Promise<T | null> {
return this.sdk.trigger({ function_id: 'state::get', payload: { scope, key } });
}
async list<T>(scope: string): Promise<T[]> {
return this.sdk.trigger({ function_id: 'state::list', payload: { scope } });
}
所有数据按 scope 组织,scope 名在 src/state/schema.ts 的 KV 常量里集中定义(44 个,schema.ts:3-75):mem:sessions、mem:memories、mem:obs:<sessionId>(每会话一个独立 scope)、mem:graph:nodes 等。
为什么观察按会话分 scope? KV.observations = (sessionId) => \mem:obs:${sessionId}`(schema.ts:5)。这样列一个会话的观察只 kv.list一个小 scope,不必扫全库——对召回回填(第 2 章enrichResults`)很关键。
5. Trigger:四种入口
Trigger 把外部事件接到函数上,agentmemory 用了四种:
| 类型 | 接什么 | 例子 |
|---|---|---|
http | REST 路由 | /agentmemory/observation → mem::observe(src/triggers/api.ts) |
durable:subscriber | 持久事件主题 | agentmemory.session.started → 建 session(src/triggers/events.ts:32-36) |
state | KV scope 变更 | mem:sessions 变了 → 推 viewer 活动事件(events.ts:141-145) |
| cron(setInterval) | 定时 | 每小时 auto-forget、每 2h consolidate(src/index.ts:539-590) |
HTTP trigger 有条安全铁律(AGENTS.md):REST 端点必须白名单字段,绝不把原始 request body 直接传给 sdk.trigger()——防止外部输入注入到内部函数。
state trigger 是个巧妙用法:event::session::observation-count-changed(events.ts:108)订阅 mem:sessions scope 的写,observationCount 增加时自动给 viewer 推一条 session.activity——实时看板不用轮询。
6. 并发:一把极简的 keyed-mutex
引擎是异步的,同一份数据的读-改-写会并发。agentmemory 不引锁库,自己写了 18 行的 withKeyedLock(src/state/keyed-mutex.ts):同一个 key 上的任务用 promise 链串起来排队,任务完成后清理 Map 条目。
// src/state/keyed-mutex.ts —— 全部(真实源码)
export function withKeyedLock<T>(key, fn): Promise<T> {
const prev = locks.get(key) ?? Promise.resolve();
const next = prev.then(fn, fn); // 接在上一个任务后面,无论成败
const cleanup = next.then(() => {}, () => {});
locks.set(key, cleanup);
cleanup.then(() => { if (locks.get(key) === cleanup) locks.delete(key); });
return next;
}
用在 obs:<sessionId>(每会话串行,observe.ts:127)和 mem:remember(全局记忆写串行,remember.ts:62)。