- 流式输出: 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
17 KiB
讨论历史
2026-05-07 项目启动与架构设计
背景
用户有一个 MSN天气API探索报告.md 文档,记录了通过抓包发现的微软 MSN 天气内部 API(assets.msn.cn),该 API 国内访问速度快,数据完整(温度、湿度、风速、AQI、紫外线等),但属于非公开接口,无 SLA 保证。
目标演变
- 最初目标:做一个"天气情报官" agent,后期结合 TTS 和 ASR 实现语音查询播报
- 深化:用户想从 0 实现一个类似 opencode 主-从架构的个人 AI 助理,解决现有单 agent 框架(zeroclaw/picoclaw)的痛点:上下文污染、工具执行懒惰、skill 效果差
- 当前范围:先做一个最小化的 CLI 天气查询工具,验证 .md 外挂 agent 定义 + session 会话管理 + 工具注册表机制
架构决策
为什么不用现有框架(LangChain 等)
- 核心创新是 .md 文件即 agent 定义,与任何框架都耦合不上
- 自实现核心 ~500 行,无外部依赖包袱
Agent 定义格式(仿 opencode)
- YAML frontmatter + Markdown body
- frontmatter 字段:name, description, type, tools, permission
- body 即 system prompt,定义角色行为
Session 会话机制
session.json文件存对话历史,格式兼容 OpenAI Chat Completion API messages 数组- 每次启动清空,每轮对话追加
- 追问时 LLM 自动判断是否需要重新调 API(数据过期/不同城市)
- 通用设计,后续 master-subagent 架构也可复用
LLM 提供商
- 用户提供豆包(火山引擎)API:
https://ark.cn-beijing.volces.com/api/v3 - 模型:
doubao-seed-2-0-pro-260215 - 环境变量可配置:
LLM_ENDPOINT,LLM_MODEL,LLM_API_KEY
工具系统
- 声明式注册:
tool.go注册工具,.md文件声明即可用 - 内置工具:
http-get,skill,read-file - skill 工具按需加载,不预置到 system prompt
Windows 编码问题
- PowerShell 输出编码为 GB2312,Go 输出 UTF-8 导致中文乱码
- 通过
kernel32.SetConsoleOutputCP(65001)设置控制台 CP 为 UTF-8 - 在 PowerShell 中需额外执行
[Console]::OutputEncoding = [Text.Encoding]::UTF8
项目结构(最终)
weather/
├── main.go # CLI 入口
├── types.go # 核心类型
├── loader.go # .md 解析 + skill 加载
├── llm.go # LLM API 封装(默认豆包)
├── tool.go # 工具注册表
├── runtime.go # agent 循环 + session
├── agents/
│ └── weather-agent.md # 天气情报官定义
├── skills/
│ └── msn-weather-api/SKILL.md
├── data/
│ └── cities.json # 42 个中国城市
├── taolun.md # 本文件
├── changelog.md # 版本变更
└── agents.md # 编码规范
验证结果
- 单次查询:
.\weather-agent.exe "北京今天天气"→ 成功返回温度、湿度、AQI 等 - 交互模式:启动后连续追问 →
session.json记录历史,LLM 基于上下文回答"适合穿什么" - 豆包 API 工具调用正常:自动读取 cities.json → 调 MSN API → 分析输出
2026-05-07 项目重命名与配置体系
变更
- 项目重命名:
weather-agent→weather-cia(CIA = 天气情报官) - 配置体系:
~/.config/weather-cli/config.yaml统一管理 LLM 配置 - 初始化方式:
weather-cia onboard交互式向导,替代手动写配置文件 - 双路径搜索:项目目录优先 +
~/.config/weather-cli/后备
关键决策
- 用 config.yaml 而非 .env/.secret:YAML 风格与 agent 定义一致,API Key 用 0600 权限保护
- 配置优先级:环境变量 > 配置文件 > 默认值(
init()中依次加载) onboard子命令:交互式 TTY 输入,自动复制默认 agents/skills/data 到全局目录- 搜索路径:
SearchFile()统一管理,开发者用项目文件,用户用全局配置
验证
weather-cia onboard成功创建~/.config/weather-cli/config.yamlweather-cia "北京今天天气"无需环境变量,直接读取配置文件中的豆包 key 并成功返回天气数据- 全局配置目录自动包含 agents/、skills/、data/ 的完整副本
2026-05-07 架构分离:Agent Skill vs 普通 Skill vs Tool
背景
参考了 picoclaw 的 weather skill 设计,对比发现:
- picoclaw 的 skill 写得很完整(含验证规则、边界情况)
- 但我们的
weather-agent.md之前 inline 了大量 API 细节 → 和 picoclaw 一样污染上下文
决策:三层分离
| 层 | 文件位置 | 加载时机 | 上下文影响 |
|---|---|---|---|
| Agent skill | agents/weather-agent.md |
启动即加载为 system prompt | 全程 |
| 普通 skill | skills/*/SKILL.md |
LLM 调用 skill("name") 时 |
仅该轮对话 |
| Tool | src/tool.go 注册 |
预声明,LLM 调用时执行 | 仅返回结果文本 |
具体改造
-
新增
geocodetool(Go 代码):- 输入城市名,调 wttr.in
?format=j1解析 JSON - 返回
{lat, lon, name, country}结构化数据 - 确定性执行,比 LLM 自己构造 URL 解析 JSON 更可靠
- 输入城市名,调 wttr.in
-
新建
skills/geocoding/SKILL.md:- 纯知识:wttr.in 查询格式、JSON 解析路径
- 验证规则:同名城市检测、country 核对、population 排序
-
精简
agents/weather-agent.md:- 去掉所有 MSN API URL、apiKey、请求头、JSON 路径等内联知识
- 改为行为描述:识别城市 → geocode → skill("msn-weather-api") → http-get → 分析
- 从 65 行缩减为 40 行,只留行为逻辑
-
session 移至
~/.config/weather-cli/session.json
结果
- Agent skill 保持瘦身,system prompt 不膨胀
- 知识按需加载,用完即走,不残留上下文
- Tool 执行可靠,不依赖 LLM 的 JSON 解析能力
- 三种内容互不干扰,为后续主-从架构打下基础
2026-05-07 项目更名:云枢·Agent
变更
- 正式命名:云枢·Agent(YunShu / yunshu)
- 坐看云卷云舒,静听花开花落
- 配置目录迁移:
~/.config/weather-cli/→~/.config/yunshu/(自动迁移) - 二进制名称:
yunshu - 架构白皮书:
~/Desktop/yunshu-architecture.md
设计理念
"云枢"二字呼应了项目作为 AI 助理"中枢调度"的定位——云是分布式的、流转的,枢是枢纽、核心。后续主-从架构中,master 负责调度、subagent 各司其职,恰如云卷云舒。
2026-05-09 Markdown 渲染器重构 + 终端输入修复
背景
原本的 pkg/mdprint/mdprint.go 是"一行流"渲染——读取 Markdown 文本后直接正则/字符串匹配输出 ANSI。问题:heading 和后续 paragraph 无法正确分离,#### 标题\n正文 导致正文也被染上 heading 颜色。
方案:OOP 风格 AST 架构
将 pkg/mdprint/ 拆分为多文件,各司其职:
| 文件 | 职责 |
|---|---|
mdprint.go |
Node 接口 + 所有块级/行内类型定义 + Print() 入口 |
parse.go |
状态机块级解析器 parseBlocks |
inline.go |
递归行内解析器 parseInline |
render.go |
renderNode type switch 渲染器 |
mdprint_test.go |
单元测试(待加) |
AST 类型设计
Node interface
├── Heading{Level, Content} → `#### 标题`
├── Paragraph{Content} → 普通文本块
├── CodeBlock{Lang, Body} → ```fenced```
├── Blockquote{Children} → > 引用
├── List{Ordered, Items} → - / * / 1. 列表
├── ListItem{Checked, Content} → 含 checkbox 支持
├── Table{Headers, Rows} → `| H1 | H2 |`
├── ThematicBreak{} → ---
├── Text{Text} → "纯文本"
├── Bold{Content} → **bold**
├── Italic{Content} → *italic*
├── Code{Text} → `code`
└── Link{Content, URL} → [text](url)
解析器流程
Print(content)→ 按\n分割为 linesparseBlocks(lines)→ 逐行状态机,识别 heading / code fence / quote / list / table / thematic-break / paragraph → 输出[]Node- 块内文本调
parseInline()→ 递归扫描,识别**/*/``/[]()→ 输出[]Node - 遍历
[]Node调renderNode()→ type switch 分发 →strings.Builder拼接 fmt.Print输出
关键修复
- Heading 和 Paragraph 是独立 AST 节点,heading 后的文本永远归 Paragraph,不会再染上 heading 颜色
- 块级解析器在 heading 行后自动切 paragraph,无需 blank line 分隔
- 代码 fence 用状态机追踪开闭
终端输入修复(2026-05-09)
- Root cause:Windows 控制台处于 character mode(
ENABLE_WINDOW_INPUT),导致bufio.Scanner和ReadString均无法获取行输入 - 修复:
ensureLineMode()在每次读之前调GetConsoleMode+SetConsoleMode设置为ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT | ENABLE_PROCESSED_INPUT ReadLine()改为bufio.NewReader(os.Stdin).ReadString('\n')(每次新建 reader),避免 Scanner 的缓冲区冲突- ANSI 样式在所有提示符和状态指示器上恢复正常显示
当前进度(后续完成)
- 2026-05-09 补充完成:
inline.go+render.go+mdprint_test.go- 行内解析器支持
**bold**/*italic*/`code`/[link](url),递归嵌套 - 修复:未闭合分隔符死循环、代码 fence 不识别语言标识
- 行内解析器支持
2026-05-09 标题视觉系统 + 真彩色支持
背景
Markdown 渲染器完成 AST 解析后,需要确定标题的终端展示风格。最初方案是保留 Markdown 的 # 前缀,但用户反馈效果不好。
标题样式演进
- 最初:
#前缀 + ANSI 颜色 → 用户觉得不好看 - 方案 A:去掉
#,用▎色块 + 加粗文字 → 讨论中用户提出▪/▫符号更好 - 方案 B:H1-H3 用
▪+ 空格,H4-H5 用▫+ 空格,H6 纯加粗 → 用户确认 - 配色:黄色/红色保留给重要信息,排除后从青/蓝/绿/品红/白中分配
- 最终使用真彩色:用户要求莫奈(印象派)配色,现有 8 色 ANSI 无法满足
最终标题配置
| 级别 | 符号 | 色值 | 色名 |
|---|---|---|---|
| H1 | ▪ |
#6B8E9B |
雾蓝灰 |
| H2 | ▪ |
#89A894 |
鼠尾草绿 |
| H3 | ▪ |
#A6C0B5 |
薄荷青 |
| H4 | ▫ |
#C3B1BD |
淡紫粉 |
| H5 | ▫ |
#7B8E8A |
暖灰绿 |
| H6 | 无 | Dim | 浅灰 |
排版规则
- 所有标题前插一个空行
- 1 级标题前后各插一个空行
---横线前后各插一个空行- 输出末尾统一加空行
- 交互模式:输入与响应之间插空行,响应与下一轮
❯之间插空行
真彩色支持方案
pkg/style/style.go新增FgHex()/BgHex()方法- 输出
\033[38;2;R;G;Bm格式的 24-bit 真彩色序列 - 底层复用
codes []string,Render()零改动 - 旧
Fg(Color)8 色 API 完全兼容,不破坏已有代码
验证
- 19/19 单元测试通过
- 构建成功,二进制运行正常
2026-05-11 会议室架构:从单 Agent 到主-从调度
背景
用户发现 weathertrends 接口失效,逐小时数据缺失。在排查中意外发现 hourlyforecast 端点存在且正常工作,文档之前遗漏了。同时回顾了与天气 Agent 的对话,发现 Agent 汇报 MSN 接口"国内城市不可用"的判断有误——实际 assets.msn.cn 按经纬度查一直正常,Agent 用的是 api.msn.cn 城市名接口(其文档本就标注"北京返回了也门萨那")。
这暴露了单 Agent 架构的局限性:Agent 的自我判断不可靠,上下文一多就容易出偏差。
讨论历程
从"PicoClaw 为什么崩"出发
| PicoClaw 痛点 | 原因 |
|---|---|
| 上下文污染 | 所有知识/工具/历史全混在同一个 system prompt |
| Skill 污染 | skill 内联大量技术细节,退化为长 prompt |
| 逃避执行 | LLM 倾向"自己回答"而非调工具 |
| 扩展困难 | 加能力 = 改代码或改长 prompt |
用户想从 0 实现一个干净的架构,先在云枢上验证,再移植到 HxClaw(河虾Claw)。
方案演进
| 轮次 | 方案 | 问题 |
|---|---|---|
| 1 | CLI 切换主 Agent(--agent weather/earthquake) |
家庭用户记不住命令,跨域查询(火山附近天气)没法做 |
| 2 | 唯一对话入口 + task 调度子 Agent | 但担心记忆管理和上下文混乱 |
| 3 | 命名空间隔离记忆 | 无法共享用户画像(住通州 → 查地震也要知道住通州) |
| 4 | 会议室模式(最终方案) | 共享黑板(记忆)+ 主持者 + 发言人,角色隔离而非数据隔离 |
关键设计决策
- 主 Agent 即对话 Agent(
type: main),用户唯一入口,扮演"个人助理"角色 - 子 Agent(
type: sub)是领域专家,被task工具调才说话,不直接面对用户 task工具负责:加载子 Agent → 查/写缓存 → 调子 Agent LLM → 返回文本- Cache 机制:Frontmatter 声明
cache.keys,task工具机械化拼 key、查/写文件 - 记忆管理员(
memory子 Agent):负责从对话中提取用户画像,写入记忆数据库 - 所有子 Agent 回答经过对话 Agent 返回给用户,保持单一入口
最终角色定义
| Agent | type | 职责 | 工具 | 缓存 |
|---|---|---|---|---|
| dialog | main | 入口 + 聊天 + 调度 | task, memory.read/write |
无(本身就是对话历史) |
| weather | sub | 查天气 | http-get, geocode, skill |
keys: [city, forecast_type], ttl: 7200 |
| earthquake | sub | 查地震 | http-get, skill |
keys: [region], ttl: 300 |
| memory | sub | 管理画像/长期记忆 | 读 memory.db | 无 |
| narrator | sub(成熟期) | 格式化回答 | memory.read |
无 |
Cache 设计
// ~/.config/yunshu/cache/weather.json
{
"<hash_of_keys>": {
"created_at": "2026-05-11T06:00:00+08:00",
"ttl": 7200,
"data": {...}, // 原始 API 数据
"raw": {"city": "北京", "forecast_type": "today"} // 原始参数
}
}
- 子 Agent 每次回答末尾可选带
---CACHE---+ JSON(只在数据更新时带) task工具查缓存:HIT → 把cache.data作为cache_data传给子 Agent;MISS → 子 Agent 自己查 API- 一个 Agent 一个缓存 JSON 文件,里面一个 map,key 是 hash
会话存储
| 类型 | 位置 | 内容 | 生命周期 |
|---|---|---|---|
| 对话历史 | session.json |
只存 user <-> dialog 的消息 | 每次启动清空 |
| 子 Agent 内部 | 临时 | tool_calls、LLM 调用 | 用完即毁 |
| 长期记忆 | 记忆数据库 | 用户画像、偏好、异常记录 | 持久 |
对比结论
| PicoClaw | 新方案 | |
|---|---|---|
| 架构 | 单 Agent 全能 | 1 主持 + N 领域专家 |
| 上下文 | 全混在 system prompt | Host 只有人格+调度,Sub 只有领域 |
| 扩展 | 改代码或改长 prompt | 加一个 .md 文件 |
| 记忆 | 无 | 共享黑板,Memory Agent 管写入 |
| 工具污染 | 所有工具混在一起 | 按角色过滤 |
| 失败影响 | 一个坏 tool_call 可能污染全部 | 子 Agent 用完即毁 |
2026-05-16 多步骤编排与文档更新
背景
用户提出真正想要的场景是"我要去北京" → 主 Agent 自动理解需要查天气、交通、住宿,依次调不同子 Agent,综合回答。当前 runtime 用 capturedOutput 覆写机制,子 Agent 结果被截走,主 Agent 没机会继续推理。
讨论:跨 Agent 数据传递
用户提出:opencode 可以在同上下文中切换不同 agent 继续工作,YunShu 是否也可以?
经过分析,结论:
task工具调用子 Agent → 子 Agent 返回 TEXT → 普通工具响应,可以留对话上下文- 主 Agent 看到结果后,可以决定再调另一个子 Agent,也可以直接回答
- 唯一需要改的:砍掉
capturedOutput覆写,让 LLM 的自然循环接管
决定:移除 capturedOutput,启用多步骤编排
在 runtime.go 去掉约 5 行代码(capturedOutput 变量、覆写逻辑),然后:
- 主 Agent 的 LLM 可以连续调多个子 Agent(weather → train → hotel)
- 数据在步骤间通过对话上下文自然传递
- prompt 告诉 LLM:信息不够就继续调,够了就综合回答
- 单步骤查询(只要天气)行为不变——LLM 按指令直接输出子 Agent 结果
同步更新文档
| 文档 | 更新内容 |
|---|---|
docs/architecture.md |
工具列表、核心流程图、当前状态树 |
docs/会议室架构计划书.md |
实现状态标记、多步骤编排章节、文件结构、调用示例 |
docs/AGENTS.md |
工具注册规范改为 NewTool[T] 方式 |
docs/changelog.md |
新增 2.0.0 版本日志 |
docs/taolun.md |
追加本次讨论 |