单 Agent 架构在业务规模较小时足够简单,但当不同团队开始要求独立的人格、工具、记忆和渠道配置,共用一个全局实例就会带来权限过宽、上下文污染与路由混乱。解决这些问题未必需要重写系统,关键是重新划分共享资源与实例隔离的边界,并让配置、会话和消息路由都能识别 Agent 归属。
多 Agent 化是 AgentHub 最大的一次架构演进——从「单 agent 全局实例」到「多个可配置 agent 实例并存」。这篇文章讲清楚:为什么要做多 agent、配置怎么组织、消息怎么路由到正确的 agent、工具/记忆/渠道怎么 per-agent 化、子 agent 怎么委派、agent 怎么创建和发布。
一点背景:这次演进并非推倒重来——session_key 从 channel:ch@t_id 扩成 agent:channel:ch@t_id、全局工具池按白名单过滤挂载、skills 改为 per-agent 显式清单,都是在原有骨架上的增量改造。演进前的单 agent 设计(消息总线、会话锁、ReAct 循环)依然成立,详见 13-整体总览.md。
一句话概括这套设计:每个 agent 拥有自己专属的 AgentLoop、工具集、记忆、渠道绑定和 Provider,通过 session_key 的 agent 前缀承载归属、通过 agent_hint/routeAgent 两条路径完成消息路由,配置上遵循「per-agent 可覆盖、缺省继承全局」这一条核心范式,让不同业务在同一进程内拥有完全隔离又共享底层工具池的独立助手。
单 agent 时代,一个进程只有一个「助手」,所有用户、所有渠道、所有业务共用同一个 SOUL、同一套工具、同一份记忆。这带来四个无法回避的问题:
多 agent 的本质就是把这四样东西(SOUL、工具、渠道、记忆)从「进程级单例」变成「per-agent 实例」,同时保留底层工具代码、MCP 连接池等可共享的部分继续共享。
每个 agent 是 agents/<name>/ 目录下一份 agent.json,核心字段:
{
"name": "my_agent",
"description": "某业务领域助手",
"soul_file": "SOUL.md",
"skills": ["doc-summarizer"],
"tools": ["read_file", "write_file", "fetch_url", "run_command"],
"max_iterations": 30,
"model": "glm-5-3",
"provider": "main",
"channels": [
{ "type": "wecom", "bot_id": "${WECOM_BOT_ID__my_agent}",
"secret": "${WECOM_SECRET__my_agent}",
"allow_from": ["zhangsan"],
"filter": { "ch@t_ids": ["某业务群"] } },
{ "type": "http" },
{ "type": "cli" }
],
"session": { "ttl_minutes": 60 },
"memory": { "consolidate_at_tokens": 80000 },
"mcp": { "servers": ["order-system"] },
"shell": { "enabled": true, "timeout_seconds": 120 },
"file_access": { "level": "system", "system_allowed_paths": ["/data/shared-docs"] },
"metadata": { "created_at": "...", "updated_at": "...", "created_by": "...",
"last_published_at": "...", "last_published_by": "..." }
}
核心设计原则:per-agent 可覆盖,缺省继承全局。这条原则贯穿 model/provider/session/memory/mcp/email/shell/file_access 全部可选字段——agent.json 里写了就用 agent 自己的,undefined 就 fallback 到全局 config/base.yaml(配置层的分层细节见 08 号笔记)。这是理解整套多 agent 体系的钥匙:不是每个 agent 都要写一份完整配置,而是只写「和全局不一样的那部分」。
agent 名字不是随便起的,validateAgentName(src/agent/definition-store.ts)统一把关:只允许小写字母/数字/连字符、以字母或数字开头、不超过 64 字符、不能以 _ 或 . 开头,且 default 是保留字——因为它是路由的最终兜底,不能被业务 agent 占用。
每个 agent 目录下放自己的 SOUL.md(文件名可由 soul_file 字段覆盖),构成该 agent 的人格和角色定义。目录结构:
agents/
├── default/ # 受保护 agent(is_protected),迁移自动生成
│ ├── agent.json
│ ├── SOUL.md
│ ├── platform-policy/ # 渠道正策文案(迁移时从 config/ 拷入)
│ ├── welcome.json # LLM 生成的欢迎语缓存
│ ├── .state # 状态机状态(draft/active/archived)
│ ├── .lock # 编辑锁(见 §13)
│ └── subagents/general/ # 兜底子 agent
├── requirements-agent/ # seedBuiltinAgents 自动创建(is_protected,内置业务 agent)
├── <your-agent>/ # 用户创建的业务 agent,每个都有同样的结构
│ ├── agent.json
│ ├── SOUL.md
│ ├── subagents/ # 预配置的子 agent
│ └── .versions/ # 发布快照(rollback 用)
│ └── 2026-09-03T06-40-17-848Z/ # 目录名是 ISO 时间戳替换字符
└── _archived/ # 归档的 agent 挪到这里
└── old_assistant/
ContextBuilder.buildSystem()(src/agent/context-builder.ts)组装 system prompt 时,优先级是:agents/<name>/SOUL.local.md(若存在,完整替换)→ agents/<name>/SOUL.md → 追加 platform-policy/<channel>.md,中间还会注入「当前时间」块(LLM 没有实时时钟,相对时间一律以此换算)。这套组装逻辑是 per-agent 的——每个 agent 用自己的 contextBuilder,组装自己的 SOUL。
AgentRegistry(src/agent/registry.ts)是管理所有 agent 的注册表,启动时 AgentRegistry.create() 加载全部 agent,并 agents/ 目录热重载(debounce 300ms)。每个 agent 加载成一个 AgentEntry,里面是该 agent 专属的一整套运行时对象:
AgentEntry = {
definition, state, // 配置 + 状态(draft/active/archived)
loop, // 该 agent 自己的 AgentLoop(不再全局单例)
toolRegistry, // 该 agent 自己的工具集(不再全局单例)
contextBuilder, // 该 agent 自己的 system prompt 组装器
consolidator, // 该 agent 自己的 L2 记忆压缩器
provider, // 该 agent 自己的 LLM Provider(model/provider 槽位均可 per-agent)
providerName, // 该 agent 的 provider 槽位名(model_hint 覆盖、子 agent 继承时用)
ctx, // 该 agent 自己的运行环境(路径/护栏覆盖)
subagents, sessionRuntimes // 子 agent
}
这里有个关键的「共享 vs 隔离」边界:工具/MCP 的来源池是共享的——builtinToolsMap 和 mcpToolsMap 在启动时全局构建一次,所有 agent 共用同一份工具代码实现;各 agent 的 ToolRegistry 只是从共享池里按 def.tools 白名单过滤挂载。也就是说,隔离的是「这个 agent 能用哪些工具」,不是「每个 agent 独立实现一套工具代码」。这样既保证了 per-agent 的工具集隔离,又避免了重复实例化 MCP 连接。
加载一个 agent(loadOne)大致十步:
agents/<name>/agent.json——注意这一步会先对 JSON 做 substituteEnv(src/agent/definition-store.ts 的 loadDefinition),把凭证类的 ${VAR} 引用解析成真值,再过 Zod 校验;ctx——该 agent 的运行环境,包含路径解析和护栏覆盖(getEffectiveFileAccessLevel 等,src/agent/context.ts)resolveModel(def.model, def.provider)——provider 槽位制解析(见第十四节):agent 可用 provider 字段选全局 llm.providers 里的槽位,model 覆盖槽位默认模型ContextBuilder,绑定该 agent 的 SOUL 路径buildToolRegistry(def)——从共享池按白名单过滤挂载工具,额外挂 run_subagent+create_subagent(闭包绑定该 agent 名)Consolidator,阈值 def.memory?.consolidate_at_tokens ?? 全局AgentLoop,把上面这些对象注入listSubagents)——注意兜底子 agent general 不是在这里 seed 的,而是创建 agent 时由 DefinitionStore.createAgent / migrateDefaultAgent / seedBuiltinAgents 写入磁盘的;运行时 getSubagent 还有一层 GENERAL_SUBAGENT 常量兜底(src/agent/subagent.ts)热重载机制: agents/ 目录变化,debounce 300ms 后重新 loadOne 受影响的 agent,替换 registry 里的 entry。配置文件改了不用重启进程。reload 后还会自动做渠道热同步(syncChannels);定义已消失(归档/删除)时移除内存 entry 并拆掉其渠道。watcher 会忽略 .lock/.state/welcome.json/.versions/_archived 这些非定义文件,避免编辑锁、快照写入触发无意义 reload。
session_key 格式从单 agent 时代的 channel:ch@t_id(如 wecom:zhangsan)变成多 agent 时代的 agent:channel:ch@t_id(如 my_agent:wecom:zhangsan)。
关键设计:InboundMessage 新增可选字段 agent_hint?: string,但 OutboundMessage/SessionLock/Session 都没有新增 agent 字段——agent 归属信息完全靠 session_key 字符串前缀承载,不是靠新增结构化字段。这样设计的好处是:出站分发、会话锁、历史持久化这些下游组件完全不用改,只要能解析 session_key 前缀就行。
// normalizer.ts:session_key 的拼装是两段式的,不是一处统一拼好
// 阶段1-normalizer:有 agent_hint 才拼 agent 前缀,无 hint 拼旧格式(不带前缀)
const session_key = parsed.agent_hint
? sessionKey(parsed.agent_hint, parsed.channel, parsed.ch@t_id) // CLI/HTTP 带 hint
: `${parsed.channel}:${parsed.ch@t_id}`; // 企微/飞书无 hint
// 阶段2-core.ts dispatch:无 hint 路径在 routeAgent 命中后,re-key 成 agent 前缀格式
if (msg.agent_hint) {
entry = this.registry.getEntry(msg.agent_hint); // hint 路径:normalizer 已拼好前缀
} else {
entry = this.registry.routeAgent(msg.channel, msg.ch@t_id, msg.user_id, msg.cred_id);
msg.session_key = `${entry.definition.name}:${msg.channel}:${msg.ch@t_id}`; // re-key
}
注意这个两段式行为:normalizer 阶段只有有 agent_hint 时才拼前缀,无 hint 时拼的是旧格式 channel:ch@t_id;真正把 agent 前缀补齐的是 core.ts 在 routeAgent 命中后的 re-key。所以「靠 session_key 前缀承载归属」这个说法,准确讲是「最终落定的 session_key 一定带 agent 前缀」,但前缀不是在 normalizer 一步到位拼好的,而是 normalizer + core.ts 两段协作完成。这样设计的好处是:normalizer 不需要知道 agent registry(它只看 hint 字段),路由决策推迟到 dispatch 阶段,职责分离更干净。
GatewayCore.dispatch(src/gateway/core.ts)原流程(命令路由→拿会话→拿锁→处理→释放)前面新增一步「找 agent」,分两条路径:
agent_hint(CLI 用 --agent= flag、HTTP 用请求体 agent 字段,默认 "default"):直接 registry.getEntry(hint) 取该 agent,不校验 active 状态(这是个已知不彻底处,文末展开)。agent_hint(企微/飞书天然不带):走 registry.routeAgent(channel, ch@tId, userId, credId),两轮匹配:routeAgent(channel, ch@tId, userId, credId) { // registry.ts
// 遍历前按名字排序,保证路由结果确定性
const entries = activeAgents.sort(byName);
// 三重前置过滤,两层匹配都套用:
// ① hasValidCredentials:占位凭证(没配真值的 env 引用)的 binding 不参与路由——
// 否则消息被派给「channel 实例被跳过」的 agent,回复静默丢失
// ② credMatches:消息带 cred_id(来源机器人标识)时,只考虑绑同一机器人的 binding——
// 防止「给 bot B 发消息、bot A 回复」的跨机器人错路由
// ③ isWildcard:空 filter({ch@t_ids: []},前端「留空=通配」保存产物)视同无 filter
// 第一轮:遍历所有 active agent 的渠道绑定,filter 精确命中即返回
for (const entry of entries) {
for (const binding of entry.definition.channels ?? []) {
if (binding.type !== channel) continue;
if (!hasValidCredentials(binding) || !credMatches(binding)) continue;
if (isWildcard(binding)) continue;
if (binding.filter.ch@t_ids?.includes(ch@tId) ||
(userId && binding.filter.user_ids?.includes(userId))) {
return entry;
}
}
}
// 第二轮:找无 filter(通配)的同渠道绑定作兜底,同样过三重前置过滤
for (const entry of entries) {
for (const binding of entry.definition.channels ?? []) {
if (binding.type === channel && isWildcard(binding)
&& hasValidCredentials(binding) && credMatches(binding)) return entry;
}
}
// 最终 fallback:default agent 必须 active,否则直接抛「无可用 agent」
const def = this.agents.get("default");
if (def && def.state.status === "active") return def;
throw new Error("无可用 agent");
}
cred_id 由 core.ts dispatch 时传入,IM 渠道入站消息会带上来源机器人标识——同一个 bot 被多个 agent 共享绑定时,靠它保证路由到绑了该 bot 的 agent。
找到 agent 后处理时调 entry.loop.process(...)——用该 agent 自己的 loop,不是全局的。
两轮匹配的语义:第一轮是「精确命中」(某个 agent 明确声明了这个 ch@t_id/user_id 归我),第二轮是「兜底接收」(某个 agent 在这个渠道开了无 filter 的口子,谁来都接)。这种设计让「专属群走专属 agent、其余走通用 agent」能自然表达。
dispatch 流程同时带流式:AgentLoop.process 多了一个 sink 回调(src/agent/loop.ts),agent turn 的最终回复由 done 事件携带 full_text 推送,不再走传统的 content reply;HTTP 渠道支持 ?stream=1/Accept: text/event-stream 的 SSE 流式输出。InboundMessage 还新增 model_hint/provider_hint 支持单轮模型覆盖——聊天窗临时切模型时,本次用 resolveModel(model_hint, provider_hint ?? entry.providerName) 临时构造 loop,不改 agent 配置,配错时降级回 agent 默认模型并 warn(聊天不该因为选了个无效模型直接白屏)。
单 agent 时代,config.channels.wecom.enabled 按凭证自动推断(!!(bot_id && secret)),全局一个渠道实例。多 agent 时代,企微/飞书 schema 里没有 enabled 字段了——渠道要不要注册,看有没有 active agent 在自己的 channels binding 里配了凭证。
// ChannelBinding schema(src/agent/types.ts)
{
type: "wecom" | "feishu" | "http" | "cli",
bot_id?: string, secret?: string, // wecom
allow_from?: string[], // wecom/feishu:连接级放行名单
app_id?: string, app_secret?: string, // feishu
filter?: { ch@t_ids?: string[], user_ids?: string[] }
}
http/cli 也走 binding(声明「这个 agent 接 http/cli 路由的消息」),但它们没有凭证、注册时按类型跳过,渠道实例仍是全局单例。
注册逻辑变成 per-agent 多实例——同一渠道类型可以有多个独立连接,每个 agent 一个。ChannelManager.register(channel, agentName?) 新增可选第二参,注册 key 变成 channel:agentName(如 wecom:my_agent);出站时从 session_key 提取 agentName,优先查 channel:agent,查不到 fallback 到 channel。
启动时的实际入口是 registerImBinding(src/channels/manager.ts),它处理一个关键场景:同一凭证(bot_id/app_id)被多个 agent 绑定时共享单条连接——同一个企微 bot 开两条 WS 连接会互踢(企微侧断旧连接),所以凭证已被占用时,旧独占实例降级为无 agent_hint 的共享实例(demoteToShared),两个 channel:agent key 指向同一连接,入站按各 agent binding 的 filter 分流;共享时 allow_from 取并集(连接级单一策略,冲突时告警)。凭证首次使用才新建 per-agent 实例;同 agent 同 bot 幂等跳过。
另有 syncChannels 热同步:对比已有 channel 和新 binding,新增的创建+start、删掉的 stop+移除、不变的跳过;共享实例被多个 agent key 引用时不因一个 agent 解绑而 stop。registry 热重载后会自动调它。
凭证在 agent.json 里存 env var 引用字符串(如 "${WECOM_BOT_ID__my_agent}"),由 loadDefinition 里的 ${VAR} 替换机制解析,不是明文——这让 agent.json 能进 git。default agent 的 binding 里写的是不带后缀的 ${WECOM_BOT_ID} 引用(向后兼容旧 .env)。
HTTP/CLI 渠道实例仍是全局单实例,走 config 全局 enabled 开关,不受 per-agent binding 驱动——per-agent 化的只是「哪个 agent 接这条渠道的消息」,不是渠道实例本身。
index.ts 启动时的注册循环大致是:遍历所有 active agent → 遍历每个 agent 的 channels binding → 跳过非 wecom/feishu 类型 → 对每个配了有效凭证的 binding 调 registerImBinding。
buildToolRegistry(def) 的过滤逻辑:
def.tools 空数组 = 继承全部(全部 builtin + 按 def.mcp.servers 过滤的 MCP 工具)def.tools 非空 = 白名单,只挂列出的工具名,未知工具 warn 跳过每个 agent 的 registry 还额外挂 run_subagent + create_subagent(闭包绑定该 agent 名,这样子 agent 委派时能知道父 agent 是谁)。MCP 工具的过滤是按 def.mcp.servers 白名单——agent 可以只用自己的 MCP 服务器子集。
有一类工具不受白名单裁剪:平台级工具 PLATFORM_TOOLS(manage_cron_jobs/read_memory/write_memory),白名单模式下也始终挂载。判定标准是「归属 agent 自身数据(无越权可能)+ 平台基础设施(有配套后台机制)」——比如裁掉 manage_cron_jobs 会导致新建 agent 没有定时任务能力,而 cron job 本身就按 agent 隔离。文件/shell/邮件/网络类工具涉及安全边界,不列入平台级,保持白名单显式授予语义。
这套设计的权衡:工具代码共享(省内存、省 MCP 连接),工具可见性隔离(安全、聚焦)。代价是白名单写错(工具名拼错)只会 warn 不会报错,可能静默丢工具——这也解释了为什么对话式创建 agent 时要提供 list_available_tools 让模型先查再选。
| 数据 | 路径 | per-agent? |
|---|---|---|
| 会话历史 | data/<agentName>/sessions/ | 是 |
| MEMORY.md(L3 记忆) | data/<agentName>/memory/MEMORY.md | 是 |
| USER.md(用户画像) | auth/USER.md | 否,全局共享 |
| workspace | workspace/<agentName>/(全部 agent 统一嵌套,含 default) | 是 |
workspace 布局经历过一次性迁移(migrateWorkspaceLayout(),src/agent/migrate.ts,启动时在 loadAll 前先跑):旧的平级布局(default 散落 workspace/ 根、其他 agent 在 workspace_<name>/)三步搬成嵌套布局——散落内容搬入 workspace/default/、workspace_<name>/ 搬入 workspace/<name>/、agent.json 里 system_allowed_paths 的 workspace_<name> 前缀改写为 workspace/<name>。幂等,新布局已就位时全部跳过。
USER.md 故意全局共享,理由是:所有 agent 服务的是同一个用户,用户画像(偏好、身份、历史交互习惯)不分 agent——不同业务的助手面对的是同一个人,没必要各自维护一份割裂的画像。而 MEMORY.md 是「这个 agent 在它的业务领域里记住了什么」,业务知识是 per-agent 的,所以分开。
Consolidator(L2 压缩)per-agent,阈值 def.memory?.consolidate_at_tokens ?? 全局。MemoryProcessor(L3 整理)遍历所有 active agent 各整理一份 MEMORY.md,USER.md 全局共享更新一份。
子 agent 委派从单一 run_subagent 扩成两个工具:
run_subagent:把子任务委派给子 agent,隔离上下文执行,只返回最终结果。subagent_type 可选(缺省用 general)。create_subagent:运行时定义临时子 agent(会话级内存,会话结束消失)。子 agent 三种来源(按优先级):
create_subagent 创建的临时子 agent)——按 sessionKey 存在 entry.sessionRuntimes 里,run_subagent 调用时把 sessionKey 传进来,同 agent 不同会话的临时子 agent 互相隔离agents/<name>/subagents/<sub>/agent.json)general(创建 agent 时 seed 到磁盘;运行时还有 GENERAL_SUBAGENT 常量兜底)compileSubagent 有三条约束:
subDef.tools 空=继承父全部,非空=白名单)run_subagent+create_subagent(防止递归派生孙子 agent,导致上下文和资源失控)subDef.model 或 subDef.provider 任一存在时用 resolveModel(subDef.model, subDef.provider ?? 父entry.providerName) 新解析一个 provider,都不配则直接复用父 provider一个容易忽略的差异:父 agent 白名单里写了不存在的工具名是 warn 跳过,而子 agent 白名单里写了不存在的工具名会直接抛错——委派链路上配置错误要 fail fast,不能让子 agent 悄悄带着残缺工具集跑。
这套设计让「主 agent 做编排、子 agent 做执行」成为可能,同时通过剔除递归工具给委派深度设了硬上限。
若 agents/default/agent.json 不存在,启动时自动跑 migrateDefaultAgent:
config/base.yaml 读 llm/session/memory/channels 配置default agent(受保护 is_protected: true,不可归档),channels 里带上 wecom/feishu/http/cli 四条 bindingconfig/SOUL.md → agents/default/SOUL.md,并把 config/platform-policy/ 整个目录拷到 agents/default/platform-policy/data/default/sessions/(文件名加 default: 前缀匹配新 session_key 格式);候选目录同时查 workspace/sessions 与 workspace/default/sessions 两处——workspace 布局迁移可能已经把旧目录搬走了USER.md → auth/USER.md,候选含 workspace/USER.md、workspace/default/USER.md、config/USER.md 三处启动 loadAll 的顺序是:先跑 migrateWorkspaceLayout(见第九节)→ default 不存在则 migrateDefaultAgent → seedBuiltinAgents。seedBuiltinAgents 会自动创建内置业务 agent(如 requirements-agent:is_protected、指定 provider 槽位、带 per-agent shell/file_access 配置),这是「内置业务 agent 随版本发布」的机制——代码是定义的唯一源,磁盘上已存在则跳过。
is_protected 是迁移 agent 的保护机制:default agent 不可 publish 离开 active、不可归档,保证系统永远有一个可用的兜底 agent。
多 agent 演进里,工具/记忆/渠道/Provider 都 per-agent 化了,Skill 这条线也跟着 per-agent 化。skills/ 目录的组织是「全局 skill(所有 agent 可用)+ 各 agent 专属目录(skills/<agentName>/<skillName>/)」,还有一套 Skill 市场体系(同步/安装/卸载走 HTTP API)。
per-agent 化体现在三处:
AgentDefinition.skills: string[] 是 per-agent 的 skill 清单。注意语义是「显式清单制」——buildSystem 只按 agent.json 的 skills 清单逐个查找加载,不扫描目录,空数组 = 无 skill。「继承全局」被前移到了创建时物化:DefinitionStore.createAgent 创建 agent 时扫描全局 skill 目录,与显式传入的 skills 合并去重后写进 agent.json,之后运行时只认这份清单。skill 市场「装到某个 agent」也是同样语义:把 skill 文件落到 skills/<agent>/ 并把名字追加进清单。ContextBuilder.findSkillMd 找 skill 文件的顺序是「agent 专属优先(skills/<agentName>/<skillName>/)> 全局(skills/<skillName>/)」。这样不同 agent 可以有同名但内容不同的 skill,agent 专属的覆盖全局的。卸载时如果全局副本还在,清单项保留(agent 仍可用全局版)。run_skill_script 工具本身没有 per-agent 权限校验——任何持有该工具的 agent 理论上能跑 skills/ 下任意脚本。这是个不彻底处:绑定是 per-agent 的(清单决定加载哪些),但执行权限没有按 agent 隔离。「显式清单制」相对「空数组继承全局」的好处:skill 市场安装/卸载一个全局 skill 不会静默改变所有存量 agent 的能力面,每个 agent 的 skill 集合可审计、可 diff;代价是新装的全局 skill 不会自动出现在老 agent 上,需要显式加进清单。
状态机:draft → active → archived
publishAgent,发布时打快照到 .versions/<timestamp>/,可 rollback),参与路由和渠道注册。archiveAgent,目录挪到 agents/_archived/),可 restoreAgent 回 draft。is_protected 的 agent(如 default)有两道拦截:不可 publish 离开 active、不可归档。
状态机之外有四个配套机制:
POST /api/agents/:name/lock 抢锁,锁是 agent 目录下的 .lock 文件(sessionId + 时间戳,TTL 10 分钟),.state 里也有 locked_by/locked_at 字段;PUT 更新定义必须持锁,防止两个编辑会话互相覆盖。实现上有个细节:更新定义用 loadDefinitionRaw 读原始 JSON(不做 env 替换),这样写回时保留凭证的 ${VAR} 引用,不会被意外物化成真值落盘。watcher 忽略 .lock 文件,抢锁不会触发热重载。POST /api/agents/:name/test 仅对 draft 开放,发布前可以先聊几句验证效果——session_key 用 draft-test:<name>:<ts> 隔离,不会污染正式会话。POST /api/agents/:name/delete 仅对 archived 开放,删定义目录 + workspace/data/skills 三处运行数据(会话历史、记忆、cron 任务);受保护 agent 不可删。先归档再删除的两步设计是给「误删 active agent」加一道缓冲。GET /api/agents/orphans 自检「运行数据目录存在但 agent 已不存在」的孤儿(覆盖 workspace/、data/、skills/ 三处),POST /api/agents/orphans/cleanup 按清单删除,删除前重算 live 集合防 TOCTOU。rollback 是「可回滚的回滚」设计:rollback 本身也是一次状态变更,rollback 前会再打一次快照,所以 rollback 之后还能再 rollback 回去,不会丢版本。
对话式创建 agent:用户和一个「元 agent」对话,通过一套画布工具边聊边搭配置草稿:
init_agent_draft / update_agent_field / add_tool / remove_tool /
add_skill / remove_skill / list_available_tools / list_available_skills /
describe_draft / finalize_draft
这些工具操作内存里的 AgentDraft,每次变更通过 SSE 推 draft_update 事件,前端实时渲染。draft 里也可以选 provider 槽位,finalize 时落进 agent.json。finalize 后才落盘调 DefinitionStore.createAgent,创建后主动 loadOne 挂载新 agent,不等 watcher。走 HTTP POST /api/agents/create-session + SSE 流。
元 agent 自己也有容错:主 provider 失败(401/网络等)且未产出任何 token 时,若 default agent 配了不同的 provider/model,会自动 fallback 到 default 的 provider 重跑(带「已切换到备用模型重试」提示)——避免「创建 agent 的 agent」因为某个槽位挂了就完全不可用。fallback 条件里还有两条防呆:要求尚未跑过工具(避免副作用翻倍)、要求确实配置了不同的 provider(resolveModel 永不返回空,必须比较配置)。
这套设计的意图:让非技术用户也能通过自然语言对话创建 agent,不用手写 agent.json。画布工具本质是「结构化字段编辑」的对话化封装,每个工具对应一个字段操作。
这是有意的取舍。session_key 已经是贯穿「会话锁、历史持久化、出站分发」的主键,给它加个 agent 前缀,下游所有组件只要会解析前缀就行,不用改数据结构。如果给 OutboundMessage/Session 加 agent 字段,就要同步改序列化、反序列化、锁粒度、历史文件命名等一堆地方,改动面大且容易漏。前缀方案的代价是:session_key 变成有语义的字符串,解析逻辑散落各处,改格式要全局影响。但相比结构化字段的改动成本,这个代价可接受。
因为「精确声明优先」是更安全的语义。第一轮找 filter 命中,意味着某个 agent 明确声明「这个 ch@t_id 归我」,这种显式声明应该优先。第二轮的无 filter 绑定是「敞开口子谁来都接」的兜底,优先级应该低。如果反过来,任何一个无 filter 的通用 agent 都会抢走本该走专属 agent 的消息,专属配置形同虚设。两轮都遍历 active agent,最终 fallback default,保证一定有 agent 接手。
严格说是 bug 倾向。getEntry 不校验 active,意味着 draft/archived 的非活跃 agent 也能被 hint 命中;而 routeAgent 路径只在 active 里选。两条路径的「能不能访问未发布 agent」语义不一致。实际危害:有人知道某个 draft agent 的名字,就能通过 CLI --agent=<draft_name> 直接调用它做测试,绕过了发布流程(当然,桌面端的正规试用通道是 /test 端点,同样只对 draft 开放)。修法是给 getEntry 加 active 校验,或者单独提供一个显式的试用路径——这是遗留清单里靠前的一项。
这个不彻底处已修复,但修复过程本身值得讲。原来的问题是「提示层/执行层不一致」:buildSystem 注入「用户授权的工作目录」这块时用的是全局配置,即使 agent 在 agent.json 里配了自己的 file_access,system prompt 里告诉 LLM 的授权目录仍是全局那份;而工具执行层的权限检查一直走 per-agent。于是 LLM 可能不知道有 agent 专属授权目录而不会主动去用,但真去访问时权限检查却按 agent 自己配的来。
修法就是把提示层对齐到执行层:buildSystem 改用 getEffectiveFileAccessLevel(this.ctx)/getEffectiveSystemAllowedPaths(this.ctx),注入的目录列表和工具执行层用同一份 per-agent 配置。子 agent 的 system prompt 组装(buildSubagentSystem)走的也是这两个函数,父子语义一致。这个修复的启示:per-agent 化改造时,「配置生效点」和「配置展示点」要成对检查,只改一边比不改更迷惑。
工具代码共享省的是内存和 MCP 连接——一个 MCP server 连一次,所有 agent 共用,不用每个 agent 各连一份。可见性隔离是通过 def.tools 白名单过滤挂载,让每个 agent 只看到自己该用的工具。如果每个 agent 独立实例化工具,内存翻倍、MCP 连接数翻倍,而且工具代码本身是无状态的(除了连接),独立实例化没有隔离收益。隔离的是「能用什么」,不是「工具实现各自一份」。
防止递归派生。如果子 agent 还能再 run_subagent,就会派生孙子 agent,孙子还能再派生,委派深度不可控,上下文和资源会指数膨胀。剔除这两个工具等于给委派深度设了硬上限一层(父→子),子 agent 只能执行,不能再编排。这是用工具可见性做硬约束,比靠 LLM 自觉可靠得多。
USER.md 是「用户画像」——这个用户是谁、偏好什么、历史交互习惯。所有 agent 面对的是同一个人,画像应该是一份,分 agent 维护会割裂且重复。MEMORY.md 是「这个 agent 在它的业务领域里记住了什么」——订单助手记住的是订单模式,规则助手记住的是规则口径,业务知识是 per-agent 的,混在一起会跨业务污染。区分的本质是「主体维度」不同:USER.md 按用户维度聚合,MEMORY.md 按 agent 维度聚合。
default 是 routeAgent 的最终 fallback,也是 migrate 自动生成的兜底 agent。如果它被归档或离开 active,那么所有「找不到专属 agent」的消息就无处可去,系统直接失去兜底能力。is_protected 保证系统永远有一个 active 的 default 可用。两道拦截(publish 离开 active、归档)是为了覆盖两种可能的误操作路径。
从「model 可 per-agent、密钥全局」的半吊子状态重构成了 provider 槽位制。全局配置是 llm.providers 一个 map(src/config/schema.ts),每个槽位有自己的 api_key/base_url/model/api 协议(anthropic = Claude messages 协议,openai = /ch@t/completions,GLM/DeepSeek 等走后者),外加一个 default_provider 指针;没有顶层特权的 model/api_key 字段,provider 是唯一事实来源。agent.json 里 provider 字段选槽位、model 字段覆盖槽位默认模型,resolveModel(def.model, def.provider) 按「agent 指定槽位 → 全局默认槽位 → providers 第一个条目」的顺序解析,配错(槽位不存在/模型缺失)直接抛错不做隐式降级——唯一的例外是 agent.json 存量 provider 指向已删除槽位时降级全局默认 + warn,避免配置重构后整个网关启动失败。
密钥按槽位 env 注入:槽位名大写拼 <NAME>_API_KEY,main 槽位固定用 LLM_API_KEY,base.yaml 里只存 ${<NAME>_API_KEY} 占位符,真实值写 .env。
这意味着「A 团队用自己的 API 额度、B 团队用另一套」做得到——不同 agent 指不同槽位,计费/限流/密钥轮转天然分开。AgentEntry.providerName 记下槽位名,供单轮 model_hint 覆盖和子 agent 继承用(见第六、十节)。
直接生成 JSON 有两个问题:一是 LLM 可能生成不合法字段或拼错工具名,校验反馈链路长;二是用户看不到中间状态,只能等 LLM 一次性吐 JSON,改起来不直观。画布工具把「字段编辑」拆成结构化操作(add_tool/remove_tool/update_agent_field),每步操作内存里的 AgentDraft 变更,通过 SSE 实时推前端渲染,用户全程看见配置在怎么变。finalize 时才落盘,中间随时可调。本质是把「生成 JSON」变成「对话驱动的结构化表单填写」,可控性和透明度都高很多。
会有窗口期。debounce 300ms 是为了合并短时间内的多次文件变更(比如保存时编辑器写临时文件再 rename),避免重复 reload。reload 时会替换 registry 里的 entry,但正在用旧 entry 处理的请求会继续用旧 entry 跑完(因为请求一开始就拿到了 entry 引用)。新请求才会拿到新 entry。所以冲突是「新旧 entry 短暂并存」,不是数据竞争。真正要小心的是 agent.json 改了工具白名单,正在跑的请求用旧工具集跑完,下一个请求才用新工具集——这是热重载的固有语义,不是 bug。
reload 之后还有两个自动动作:一是渠道热同步(syncChannels),binding 增删立即反映到连接层,不用重启;二是定义已消失时(归档挪进 _archived、目录被删)loadOne 会抛错,此时移除内存 entry 并拆掉其渠道——否则归档 agent 会以 active 状态残留在路由里、机器人连接也断不掉。另外 watcher 会忽略 .lock/.state/welcome.json/.versions,编辑锁、状态写入、快照这些非定义变更不会触发 reload,否则抢个编辑锁就全量重载一次,太浪费。
这套体系曾经明确的几处不彻底处,现在的状态:system prompt 注入(提示层/执行层不一致)和 Provider 密钥(槽位制)已修复;shell 开关修了一半(执行层 per-agent 可关、注册层仍全局);剩下的硬骨头是 agent_hint 路径的 active 校验和 skill 执行层无 per-agent 权限校验。
演进路线整体是从「够用优先」逐步走向「边界补齐」:先让多 agent 跑起来(隔离 + 共享 + 路由),再逐项修「语义不一致」的地方(提示层、密钥),最后清安全边界的账(hint 校验、skill 执行权限)。每个已修复项都是「先识别不彻底处、再排期补齐」的迭代素材——对这类渐进式重构,把不彻底处显式记下来比假装它不存在有价值得多。
AgentHub 的多 agent 体系用「session_key 前缀承载归属 + per-agent 覆盖缺省继承全局」这两条核心范式,在共享底层工具池和 MCP 连接的前提下,实现了 SOUL、工具集、记忆、渠道绑定、Provider 槽位的 per-agent 隔离,配套状态机(含编辑锁/试用/彻底删除/孤儿清理)、迁移、对话式创建支撑全生命周期。其中最值得复用到其他系统的三个决策:一是归属信息放在既有主键(session_key)里而不是新增结构化字段,让下游组件零改动;二是「工具代码共享、工具可见性隔离」,用白名单做安全边界而不用实例复制;三是用「剔除递归工具」这种结构性硬约束,而不是 prompt 约束,去堵住委派深度失控的可能。