Files
YunShu/docs/会议室架构计划书.md
titor c4a0e3ef53 feat: v2.3.0 流式输出 + 日志系统 + 会议室架构全面升级
- 流式输出: SSE 逐 token 接收, \\n\n\ 段落缓冲后 mdprint 彩色渲染
- 日志系统: charmbracelet/log v2 双写(stderr + log.yml), yunshu log 命令
- 会议室架构: dialog(main) + weather/profile/note(sub) 多 Agent 编排
- 泛型工具注册: NewTool[T] 反射推导 JSON Schema, 类型安全
- 安全加固: safeMemoryPath 三段校验(EvalSymlinks+Rel), maxToolCalls=2
- 性能优化: sync.Once 延迟加载, note 一步完成, obs/summary 合并
- Prompt 适配: 流式输出原则(先调工具不说话), 单 Agent 查询跳过 obs+summary
- 文档: AGENTS.md + architecture.md + changelog.md 全部同步至 v2.3.0
2026-05-16 17:21:29 +08:00

536 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 云枢·Agent 会议室架构计划书
> **生成日期**2026-05-11
> **最后更新**2026-05-16
> **目的**:从单 Agent 架构升级为"会议室模式"1 主持 + N 领域专家 + 共享黑板)
> **最终目标**:在云枢上验证通过后,移植到 HxClaw河虾 Claw
>
> **实现状态**
> - ✅ 核心引擎registry + runtime + cache + session
> - ✅ 7 个工具task, memory.read/write, http-get, skill, read-file, geocode
> - ✅ 泛型+反射工具注册NewTool[T]
> - ✅ 多步骤编排(主 Agent 连续调多个子 Agent
> - ✅ weather-sub.md 天气子 Agent
> - ❌ memory-sub.md 记忆管理员子 Agent
> - ❌ earthquake / train / hotel 等扩展子 Agent
---
## 一、架构总览
```
用户
┌──────▼──────────────────────────────────────────┐
│ 主持者dialog-agenttype: main │
│ 人格 + 调度规则 + task + memory 工具 │
│ 唯一入口,用户只和它对话 │
│ ✨ 可以连续多次调不同子 Agent综合数据后回答 │
└──────┬───────────────────────────────────────────┘
│ task("weather", {city: "北京"}) ← 第一步
│ ← 北京明天 5°C 晴
│ task("train", {city: "北京", date: "明天"}) ← 第二步(看到天气后决定)
│ ← G102 08:00 ¥680
│ task("hotel", {city: "北京", nights: 3}) ← 第三步
│ ← 建国饭店 ¥500/晚
│ → 综合: "明天北京5°C…G102早8点…建国饭店…" ← 最终回答
┌───────────────────────────────────────────────┐
│ 发言人(领域子 Agenttype: sub │
│ weather / earthquake / memory / narrator │
│ 被调才说话,返回文本 + 可选缓存数据 │
│ 各自的 cache / skills / tools 互相隔离 │
│ 不感知其他子 Agent 存在,结果由主 Agent 整合 │
└───────────────────────────────────────────────┘
│ 读写
┌───────────────────────────────────────────┐
│ 记录者(记忆系统) │
│ 共享黑板:用户画像、偏好、异常记录 │
│ memory Agent 负责从对话中提取有价值信息 │
│ 所有 Agent 只读,仅 memory Agent 写入 │
└───────────────────────────────────────────┘
```
---
## 二、角色定义
### 2.1 主持者dialog-agenttype: main
**职责**
- 用户的唯一入口
- 有血有肉的个人助理,能闲聊
- 识别用户意图,**可以连续多次**调度不同的子 Agent
- 每次 task() 返回后,决定继续调下一个还是综合回答(多步骤编排)
- 把上一步子 Agent 的结果作为上下文,传递给下一次 task() 的参数
- 读/写记忆(用户画像、上下文摘要)
**工具列表**
- `task` — 调度子 Agent
- `memory.read` — 读长期记忆
- `memory.write` — 写长期记忆
**System Prompt 包含**
- 人格(从 `memory.personality` 加载)
- 调度规则(何时调哪个子 Agent
- 不含任何领域知识
**System Prompt 不包含**
- 天气知识、地震知识等
- `http-get``geocode` 等具体工具
**Session**
- `session.json` 只存 user ↔ dialog 的对话轮次
- 子 Agent 内部的 tool_calls 不写入
### 2.2 发言人weather-subtype: sub
**职责**
- 响应天气查询
-`task` 调才执行,不直接面对用户
- 返回显示文本 + 可选缓存数据
**工具列表**`http-get`, `geocode`, `skill`
**Frontmatter**
```yaml
name: weather
type: sub
description: 天气查询专家
cache:
ttl: 7200
keys: ["city", "forecast_type"]
tools:
- http-get
- geocode
- skill
```
### 2.3 发言人earthquake-subtype: sub预留
**职责**:响应地震信息查询
**Frontmatter**
```yaml
name: earthquake
type: sub
description: 地震信息查询
cache:
ttl: 300
keys: ["region", "time_range"]
tools:
- http-get
- skill
```
### 2.4 记录者memory-subtype: sub
**职责**
- 阅读对话历史,提取用户画像
- 把有价值的信息写入长期记忆数据库
- 响应其他 Agent 的记忆查询
- 记录子 Agent 的异常(如 API 失效)
**工具列表**`memory.read`, `memory.write`, `read-file`, `write-file`
**Frontmatter**
```yaml
name: memory
type: sub
description: 记忆管理员
tools:
- memory.read
- memory.write
- read-file
- write-file
```
### 2.5 汇报员narrator-subtype: sub成熟期
**职责**:把结构化数据翻译成个性化回答
```yaml
name: narrator
type: sub
description: 个性化回答生成器
tools:
- memory.read
```
---
## 三、核心工具task
### 3.1 职责
```
task(agent_name, arguments)
├── 1. 加载 {agent_name}-sub.md Frontmatter
│ ├── name, type, tools, cache
│ ├── cache.keys → ["city", "forecast_type"]
│ └── cache.ttl → 7200
├── 2. 拼缓存 key
│ ├── 遍历 cache.keys → 从 arguments 提取值
│ ├── 拼接 → "city=北京&forecast_type=today"
│ └── hash → "a1b2c3d4e5f6"
├── 3. 读缓存文件 ~/.config/yunshu/cache/{agent_name}.json
│ ├── HIT → cache_data = {temp: 25, ...}
│ └── MISS → cache_data = null
├── 4. 调子 Agent LLM
│ ├── system = {agent_name}-sub.md 内容
│ ├── user = {
│ │ "args": arguments,
│ │ "cache_data": cache_data // 有缓存传数据,没有传 null
│ │ }
│ └── 子 Agent 返回文本 + 可选 ---CACHE--- + JSON
├── 5. 处理子 Agent 返回
│ ├── 有 ---CACHE--- → 提取后面的 JSON → 写缓存
│ └── 无 ---CACHE--- → 只传文本
└── 6. 返回显示文本给 Hostdialog Agent 的 LLM
```
### 3.2 缓存文件格式
```json
// ~/.config/yunshu/cache/{agent_name}.json
{
"<hash>": {
"created_at": "2026-05-11T06:00:00+08:00",
"ttl": 7200,
"data": {
"temp": 25,
"condition": "晴"
},
"raw": {
"city": "北京",
"forecast_type": "today"
}
}
}
```
- hash 由 `cache.keys``arguments` 中提取值 → 拼接 → SHA256 取前 12 位
- `raw` 存原始参数,方便调试和遍历
- 每次读缓存时惰性清理过期条目
### 3.3 子 Agent 返回协议
子 Agent 返回分两段,由 `task` 工具解析:
```
---RESULT---
{结构化 JSON 数据(进缓存,不进 dialog 上下文)}
---TEXT---
子 Agent 想要对用户说的陈述文本(进 dialog 上下文)
```
- `---RESULT---`:原始 API 数据task 写入缓存文件,**不传给 dialog**
- `---TEXT---`:子 Agent 已经组织好的陈述文本task 返回给 dialog 的 LLM
**为什么分成两段**
- RESULT 保持子 Agent 的领域数据干净,不进主上下文
- TEXT 给 dialog 一个"素材"dialog 用自己的语气说出来,不会产生"复述感"
- 如果子 Agent 这次没有更新数据(比如 cache 命中后直接回答),可以只带 `---TEXT---`
### 3.4 传给子 Agent 的参数
```json
{
"args": {
"city": "北京",
"forecast_type": "today",
"units": "C"
},
"cache_data": {
"temp": 25,
"condition": "晴"
}
}
```
- `args` 是 dialog 传过来的原始参数
- `cache_data` 是缓存的数据(有缓存时),子 Agent 据此直接回答,省一次 API 调用
- 两者都是原始数据,不是处理过的文本
---
## 四、记忆系统
### 4.1 存储位置
```
~/.config/yunshu/memory.db (或 memory.jsonMVP 阶段)
```
### 4.2 数据模型
```json
{
"personality": "你是个幽默风趣的北京大妞,说话带点贫",
"user_profile": {
"location": "北京通州",
"unit": "C",
"allergies": ["花粉"],
"interests": ["户外"],
"mood_today": null
},
"agent_errors": {
"weather": ["msn_api_500 at 2026-05-11T06:00:00"],
"earthquake": []
},
"dialog_context": {
"last_agent": "weather",
"last_topic": "北京天气",
"summary": "用户问了北京天气"
}
}
```
### 4.3 读写规则
| 操作 | 谁做 | 时机 |
|------|------|------|
| `memory.read` | dialog / 子 Agent | 需要画像时 |
| `memory.write` | 只有 memory Agent | 从对话中提取画像后 |
| `memory.write("dialog_context")` | dialog | 每次回答后 |
### 4.4 memory Agent 的工作流
```
用户: "我住北京通州,最近花粉过敏厉害"
→ dialog 聊天回应
→ dialog: task("memory", {action: "extract", text: "用户说住通州、花粉过敏"})
→ memory: 提取 → memory.write("user_profile.location", "北京通州")
memory.write("user_profile.allergies", ["花粉"])
用户: "今天天气怎么样?"
→ dialog: task("memory", {action: "read_context"}) → 有 location
→ dialog: task("weather", {city: "北京通州"})
```
---
## 五、文件结构
```
yunshu/
├── main.go # CLI 入口
├── types.go # 核心类型AgentDef, Schema, ToolDef, Message…
├── loader.go # .md 解析Frontmatter + Body
├── catalog.go # CatalogAgent 生成 + tools.yml 输出
├── registry.go # Agent 注册中心(扫描 + 按 type 分类)
├── llm.go # LLM API 封装
├── tool.go # 工具注册 + 7 个工具 handler
├── toolschema.go # 泛型+反射 Schema 生成NewTool[T], structToSchema
├── runtime.go # RunAgent + RunSubAgent + cache + session
├── agents/
│ ├── dialog-agent.md # type: main — 主持者
│ ├── weather-sub.md # type: sub — 天气 ✅
│ ├── earthquake-sub.md # type: sub — 地震(预留)❌
│ ├── memory-sub.md # type: sub — 记忆管理员 ❌
│ └── narrator-sub.md # type: sub — 汇报员(成熟期)❌
├── skills/
│ ├── msn-weather-api/SKILL.md
│ └── geocoding/SKILL.md
├── docs/
│ └── 此目录
└── pkg/
├── mdprint/
├── style/
└── termui/
```
### 用户配置目录
```
~/.config/yunshu/
├── config/
│ ├── config.yml # LLM 配置
│ ├── user.md # 用户画像(## 画像 / ## AI观察到
│ └── soul.md # AI 灵魂(用户可编辑)
├── session/
│ ├── session.json # 对话历史(仅 user ↔ dialog
│ └── dialog.yml # 对话摘要(每轮覆写)
├── notes.md # 备忘录列表
├── notes/ # 独立笔记文件
├── log.yml # API 异常记录
├── cache/
│ ├── weather.json
│ ├── earthquake.json
│ └── ...
├── data/
│ └── weather/ # 子 Agent 自己的数据目录
└── memory.db # 长期记忆数据库
```
---
## 六、调用流程示例
### 6.1 单子 Agent 查询
```
用户: "北京明天多少度?"
HOSTruntime.go:
1. 加载 dialog-agent.md → system prompt
2. 读 session.json → 恢复上下文
3. 调 LLMsession + system + tools
4. LLM 返回 tool_call: task("weather", {city: "北京", forecast_type: "tomorrow"})
task 工具(子 Agent 调用):
1. 加载 weather-sub.md Frontmatter
→ cache.keys: ["city", "forecast_type"], ttl: 1800
2. 拼 key → "city=北京&forecast_type=tomorrow" → sha256[:6]
3. 查 cache/weather.json → MISS
4. 调子 Agent LLMRunSubAgent隔离的循环
system = weather-sub.md
user = {args: {city: "北京", forecast_type: "tomorrow"}, cache_data: null}
5. 子 Agent 工具链:
├── skill("msn-weather-api") → 接口参数
├── geocode("北京") → (39.9, 116.4)
├── http-get(URL) → JSON
└── 返回:
---RESULT---
{temp: {lo:18, hi:31}, condition: "晴"}
---TEXT---
▪ 北京明天天气
...
6. task 提取 RESULT → 写 cache/weather.json
7. 返回 TEXT 给 HOST
HOSTruntime.go:
1. tool 结果 → 追加到对话 → LLM 再次推理
2. LLM 根据 prompt 指令"子 Agent 输出就是答案"→ 直接输出 TEXT
3. 追加 session.json
4. 显示给用户
```
### 6.2 多步骤编排(新增能力)
```
用户: "去北京出差,明天走,待三天"
HOSTruntime.go:
1. 加载 dialog-agent.md → system prompt
2. 读 session → 恢复上下文
3. 调 LLMsession + system + tools
┌─ 第 1 轮 LLM 推理 ──────────────────────────────┐
│ LLM 决定: 先查天气 │
│ tool_call: task("weather", {city:"北京", │
│ forecast_type:"tomorrow"})
│ → 子 Agent 返回: 北京明天 5°C 晴 │
│ → 工具结果追加到对话 │
└──────────────────────────────────────────────────┘
┌─ 第 2 轮 LLM 推理 ──────────────────────────────┐
│ LLM 看到天气结果, 决定查火车票 │
│ tool_call: task("train", {city:"北京", date:"明天"})│
│ → 子 Agent 返回: G102 08:00 ¥680 │
│ → 工具结果追加到对话 │
└──────────────────────────────────────────────────┘
┌─ 第 3 轮 LLM 推理 ──────────────────────────────┐
│ LLM 看到天气+车次, 决定查酒店 │
│ tool_call: task("hotel", {city:"北京", nights:3}) │
│ → 子 Agent 返回: 建国饭店 ¥500/晚 │
│ → 工具结果追加到对话 │
└──────────────────────────────────────────────────┘
┌─ 第 4 轮 LLM 推理 ──────────────────────────────┐
│ LLM 觉得信息够了 → 返回文本 │
│ "明天北京5°C记得带外套。G102早8点¥680。 │
│ 建国饭店3晚¥1500。总预算约¥2180。" │
│ → 追加 session.json → 显示给用户 │
└──────────────────────────────────────────────────┘
```
---
## 七、实施阶段 — 当前状态
### 阶段一:基础架构(已完成,超计划完成)
| 步骤 | 文件 | 状态 | 说明 |
|------|------|------|------|
| 1.1 | `types.go` | ✅ | `AgentDef``Type string``Cache *CacheDef``Schema` 替代 `ToolParameter` |
| 1.2 | `loader.go` | ✅ | Frontmatter 解析加 `type``cache` 字段 |
| 1.3 | `registry.go` | ✅ | `ScanAgents()` 扫描按 type 分类,同名覆盖 |
| 1.4 | `tool.go` | ✅ | `task` / `memory.read` / `memory.write` + 4 个原有工具 |
| 1.5 | `runtime.go` | ✅ | `RunSubAgent` + `RunAgent` + cache + session |
| 1.6 | `toolschema.go` | ✅ ✨ | **新增(计划外)** — 泛型+反射 `NewTool[T]` 替代手写 Schema |
| 1.7 | `main.go` | ✅ | `ScanAgents().GetMain("dialog")` 动态注入子 Agent 列表 |
| 1.8 | `agents/dialog-agent.md` | ✅ | 主持者,含多步骤编排指令 |
| 1.9 | `agents/weather-sub.md` | ✅ | 天气子 AgentMarkdown 输出 + 生活建议 |
| 1.10 | — | ✅ ✨ | **多步骤编排(计划外)** — 砍掉 `capturedOutput`,主 Agent 连续调多个子 Agent |
#### 计划外新增内容
1. **泛型+反射工具注册**`toolschema.go`
- `NewTool[T any]()` 泛型构造函数,自动反射推导 JSON Schema
- 输入结构体 + struct tags → 零模板代码的工具注册
- handler 内参数为类型安全的结构体字段,无需 `args["x"].(string)`
2. **多步骤编排**`runtime.go` 改造):
- `capturedOutput` 覆写机制已移除
- 子 Agent 结果作为普通工具响应留在对话上下文
- LLM 可以连续多次调 `task()`,直到信息收集完毕再回答
### 阶段二:记忆系统(待开始)
| 任务 | 文件 | 状态 |
|------|------|------|
| 2.1 memory-sub.md | 记忆管理员 Agent从对话提取画像 | ❌ |
| 2.2 记忆数据库 | 结构化存储(画像、偏好、异常记录) | ❌ |
| 2.3 画像自动提取 | memory Agent 定期从对话中提取有用信息 | ❌ |
### 阶段三:扩展(待开始)
| 任务 | 说明 | 状态 |
|------|------|------|
| 3.1 earthquake-sub | 地震信息查询 | ❌ |
| 3.2 train-sub | 火车票查询 | ❌ |
| 3.3 hotel-sub | 住宿查询 | ❌ |
| 3.4 narrator-sub | 个性化回答生成(成熟期) | ❌ |
---
## 八、与 PicoClaw 的对比
| 维度 | PicoClaw | 云枢·会议室模式 |
|------|----------|----------------|
| 入口 | 单 Agent用户直接对话 | 对话 Agent唯一入口+ 背后一堆子 Agent |
| 上下文 | 所有轮次 + 系统 prompt 混在一起 | session 只存 user↔dialog子 Agent 用完即毁 |
| 知识 | 预置或长 prompt | skill 按需加载 |
| 工具 | 所有工具混着用 | 按角色过滤dialog 只有 task + memory |
| 记忆 | 无 | 共享黑板memory Agent 管写入 |
| 扩展 | 改代码或改 prompt | 加一个 .md 文件 |
| 失败隔离 | 坏 tool_call 可能污染全部 | 子 Agent 独立,坏就坏一个 |
| 用户自定义 | 不可能 | 在 `~/.config/yunshu/agents/` 放 .md 即可 |
---
## 九、设计原则
1. **主持者保持极薄** — 只有人格 + 调度规则,不做领域知识
2. **子 Agent 不自知** — 不知道缓存存在、不管理自己的 session只回答当前问题
3. **机械化的不做 LLM** — 缓存 key 拼装、文件读写都是 Go 代码LLM 不参与
4. **数据隔离** — 子 Agent 的 cache 文件、data 目录互相独立
5. **记忆共享** — 黑板机制,所有 Agent 可读,仅 memory Agent 可写
6. **一个入口** — 用户永远只和 dialog-agent 对话,感受不到子 Agent 的存在