跳到主要内容

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.tsKV 常量里集中定义(44 个,schema.ts:3-75):mem:sessionsmem:memoriesmem: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 用了四种:

类型接什么例子
httpREST 路由/agentmemory/observationmem::observe(src/triggers/api.ts)
durable:subscriber持久事件主题agentmemory.session.started → 建 session(src/triggers/events.ts:32-36)
stateKV 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)。

7. 这个底座为什么重要

  • 零运维依赖:没有 Postgres/Redis/向量库要装。README 卖点「0 external DBs」是真的——KV 是引擎的 SQLite,索引是进程内存。
  • 语言无关的 API:函数注册在引擎上,任何有 iii SDK 的语言都能直接 ws://localhost:49134mem::remember,不用每语言写一个 REST 客户端(README.md:692)。
  • 统一抽象省代码:HTTP/事件/state/cron 都收敛成「trigger → function」,所以 258 个函数能挤在 ~37,800 行里——没有框架样板。

最后一章:精华、边界、对比、代码地图 → 05-clever-and-boundaries.md