AI Agent
第二章 yumawiki:给项目造一张会指路的架构地图
从传统 Agent 改代码时 token 浪费、上下文过载导致记忆丢失等问题出发,讲 yumawiki 的目标、架构、实现与总结。
- Claude
- Agent
- RAG
- MCP
第一章把 Skill、MCP、RAG、mini-agent 四个零件逐个拆开看过。这一章把它们组装成一个能在真实项目里运行的东西:yumawiki。但在讲它怎么做之前,得先说清楚为什么要做——也就是传统 Agent 改代码时到底卡在哪里。
这一章讲的是 yumawiki 这套系统本身怎么设计。它生成的架构地图是另一回事,由
/start-wiki产出,放在.claude/yumawiki/里给 agent 看。
一、传统 Agent 跑起来,有几个绕不开的问题
让 agent 改一段代码,它通常这么做:
"改主题切换逻辑"
→ grep 或翻 src/,遍历仓库找位置
→ 打开 5 个文件、每个几百行
→ 最终定位到 src/layouts/Layout.astro:103
这套动作有三个具体的问题。
第一,定位靠通读,token 大量浪费在”找”上。 真正要改的可能就几行,但为了找到这几行,agent 要把整个仓库翻一遍。仓库越大、改动越频繁,这个代价越高,而且每次改动都得重来。
第二,上下文塞太满,模型会”忘事”。 把十几个文件、每个几百行全塞进上下文,看起来信息充分,实际反而有害。长上下文里模型对中间内容的注意力会下降(即所谓”中间遗忘”),早期的目标和指令容易被后面塞进来的一大堆代码稀释,导致它改着改着偏离初衷,或反复读同一段。上下文不是越大越好,塞满了准确度反而下降。
第三,没有积累,每次从零开始。 这次读完得到的结论,下次还得重读一遍。agent 之间、会话之间不共享对项目的理解,重复劳动。
二、我想做成什么样
针对上面三个问题,yumawiki 想换一种方式。
针对”找位置靠通读”:先做语义检索,精确定位到 文件:行号,agent 只读那几行,不再通读整个仓库。
针对”上下文塞太满”:用一张分层地图给出全局结构,agent 先看地图确定改动属于哪一层、该往哪找,再下钻到具体行,避免一次性把大量代码塞进上下文。
针对”没有积累”:地图和索引都落盘——分层 wiki 写到 .claude/yumawiki/,向量缓存写到 .rag-cache.json,下次直接复用,不用从零再读。
目标可以概括成一句:提升改代码的效率,大幅减少 token,不让上下文过载,从而避免”记忆丢失”。
三、架构:一张地图 + 一个检索索引
yumawiki 由两部分组成。一部分是分层的架构 wiki,提供全局结构,回答”某个改动属于哪一层”;另一部分是语义检索索引,提供精确坐标,回答”某段逻辑在哪个文件第几行”。两者配合,agent 先看地图定层、再用检索锁行,最后只读必要的片段。
它正好由第一章的四个零件组装:
/start-wiki ──► 命令(.claude/commands) 入口
│
┌───────────┴────────────┐
▼ ▼
mcp index(建索引) Task 启动子代理 编排(skill 串起来)
│ │
▼ ▼
raglib 引擎 ──GLM──► 子代理调 query/locate 引擎 / 写作
walk→切片→向量化 │
│ ▼
▼ 写 .claude/yumawiki/
.rag-cache.json README + 01..07
| 零件 | 对应第一章 | 在 yumawiki 里的职责 |
|---|---|---|
| MCP | 案例 2(本地 MCP) | yumawiki-rag:把仓库向量化,暴露检索/定位工具 |
| RAG | 案例 3(本地知识库) | 引擎核心:切片 → GLM 向量化 → 余弦检索 |
| Agent | 案例 4(mini-agent) | yumawiki 子代理:调 MCP 收集结构,写分层 wiki |
| Skill | 案例 1(frontmatter) | yumawiki skill:编排”建索引 → 启动子代理 → 核对” |
可以说,yumawiki 不是新发明,而是第一章四个零件的一次真实组装。
四、实现
1. RAG MCP:把代码变成可语义定位
切片要带行号。 第一章的 RAG demo 只切 .md、不记行号。yumawiki 要切所有源码(.astro/.ts/.css/.yml/...),而且每个片段都要带上它在文件中的行号,否则检索到了也说不清位置。
// 精简自 agent-demos/rag/raglib.mjs
// code:滑动 40 行窗口切,每片记 startLine / endLine
function chunkCode(text, maxLines = 40, overlap = 6) {
const lines = text.split("\n");
const out = [];
for (let i = 0; i < lines.length; i = end - overlap) {
const end = Math.min(i + maxLines, lines.length);
out.push({ start: i + 1, end, text: lines.slice(i, end).join("\n") });
if (end >= lines.length) break;
}
return out;
}
向量化 + 余弦检索 + 按文件定位。 复用第一章的 GLM embedding-3,并增加一个按文件聚合的 locate:query 出一批片段后,按文件归并、每个文件取最佳片段,直接回答”实现这个特性的文件是哪几个”。
// 按特性定位文件(精简)
const hits = await query(index, "主题切换持久化", { topK: 12 });
// → src/layouts/Layout.astro:103-142 (score 0.523)
// → src/content/docs/nest/index.md:583 ...
增量缓存,省嵌入成本。 全量向量化一次要花不少 API 调用。yumawiki 给每个片段算一个内容 hash,只对内容变化的片段重新嵌入,其余复用旧向量:
// 内容没变 → 复用缓存向量;变了 → 重新嵌入
const vector = prevByHash.get(chunk.hash) || (await embed([chunk.text]))[0];
实测:改一个文件后重建,516 个片段里只有 1 个重新嵌入,其余 515 个复用。
暴露成 5 个 MCP 工具。
| 工具 | 作用 |
|---|---|
status | 索引是否已建、片段数、是否过期 |
index | 增量重建(hash 复用) |
query | 语义检索,返回带 file:LINE 的片段 |
locate | 按特性定位文件(聚合去重),省 token 的核心 |
list-layers | 返回 7 个架构分层及代表文件 |
2. 子代理:把检索结果写成可点击的地图
子代理拿到 MCP 返回的 file:LINE 和片段后,把它们组织成分层 wiki:把项目拆成 7 层,每层一个文件。
| 层 | 管什么 | 代表文件 |
|---|---|---|
| 1 内容 | Markdown 集合 + schema | src/content.config.ts、src/content/{docs,topics} |
| 2 路由 | 页面路由 → 取数据 → 渲染 | src/pages/docs/[...slug].astro |
| 3 布局 | 页面外壳 + 主题/侧栏 + 组件 | src/layouts/Layout.astro、InPageSearch.astro |
| 4 样式 | Tailwind4 + daisyUI + 主题 token | src/styles/global.css |
| 5 构建配置 | astro.config / tsconfig / package | astro.config.mjs |
| 6 CI/部署 | Gitee Go → Nginx | .workflow/流水线-*.yml |
| 7 Agent | .claude 扩展 + agent-demos + MCP | .claude/、agent-demos/ |
每个分层文件的结构是这样的:
# 3. 布局/渲染层
<这层负责什么>
## 结构图
<一张 mermaid> ← 地图本体
## 组件清单
| 组件 | 代码位置 | 说明 |
| 主题持久化 | src/layouts/Layout.astro:103 | localStorage 读写 |
## 数据流 / 调用链
src/pages/docs/[...slug].astro:18 → Layout.astro:294 → ...
## 改动提示
改主题前先看 Layout.astro:103;它会影响 ...
这里有两个关键约定。第一,代码引用一律写成 相对路径:行号,比如 src/layouts/Layout.astro:103,这样 agent 能直接读、IDE 能点开,比”在某处大概”有用得多。第二,mermaid 产出在 .claude/ 里,由 agent 看、由编辑器渲染;这篇博客文章没有配 mermaid 渲染,所以这里用 ASCII 示意图代替。
子代理还有一条硬约束:每个分层文件恰好一张 mermaid、至少 3 个 path:LINE 引用,避免写成流水账。
3. 一次 /start-wiki 怎么跑
输入 /start-wiki 后,命令先调用 mcp index,让引擎遍历仓库、切片、向量化、写缓存;然后用 Task 启动 yumawiki 子代理(mode=full)。子代理通过 list-layers 锁定 7 层,对每层调 locate/query 拿到带行号的代表代码(只在必要时才 Read 大文件的局部,不整文件读),最后写出 .claude/yumawiki/ 下的 README.md 和 01-content.md … 07-agent.md,并把 README 主图展示出来。
/update-wiki 走的是同一条路,区别在于 mode=update:保留结构没变的 mermaid,只根据最新索引重写文字和组件清单。
五、上手
两个命令:
/start-wiki:全量生成,第一次或大改结构后用。/update-wiki:增量刷新,平时改了代码后用,只重新嵌入变化的部分。
更关键的是写进 CLAUDE.md 的那条工作流,它让 agent 以后每次改代码都自动走这条路:读 README 主图确定属于哪一层,用 locate/query 拿到 file:LINE,只 Read 那几行,改完再用 /update-wiki 刷新地图。相比过去 grep、重读整个仓库找位置,token 消耗显著降低,上下文也不再被无关代码塞满。
前置:yumawiki-rag 是新注册的 MCP,第一次用要重启 Claude Code 并通过信任对话框(claude mcp list 看到 yumawiki-rag ✔ Connected 即可)。注册命令(项目根目录):
claude mcp add yumawiki-rag --scope local -- node agent-demos/rag/server.mjs
跑起来:
# 1) 在 Claude Code 里生成架构地图
/start-wiki
# 2) 以后改了代码,刷新
/update-wiki
# 3) 验证检索质量(命令行直测引擎)
node --input-type=module -e "
import * as rag from './agent-demos/rag/raglib.mjs';
const idx = rag.loadIndex();
const hits = await rag.queryIndex(idx, '主题切换持久化', { topK: 3 });
console.log(hits.map(h => h.file + ':' + h.startLine));
"
# → ['src/layouts/Layout.astro:103', ...]
embedding 用的是 GLM embedding-3,API key 从环境变量 ANTHROPIC_AUTH_TOKEN 读(和第一章的 demo 一致),不写进仓库。请求地址在 agent-demos/rag/raglib.mjs 里。
六、总结
回到开头那三个问题。传统 Agent 的毛病在于:找位置靠通读(费 token)、上下文塞太满(导致记忆丢失)、没有积累(每次重来)。yumawiki 的做法是用语义检索把”找”变成”查”,用分层地图让 agent 先定向再下钻,把地图和索引落盘复用。
第一章讲了四个零件,这一章把它们装成一台能跑的机器:MCP 当引擎和接口(向量化、检索、定位),RAG 是引擎内核(切片、嵌入、余弦),子代理当作者(把检索结果组织成分层地图),Skill 和命令当开关(/start-wiki、/update-wiki),CLAUDE.md 把”改代码前先查”固化成习惯。
需要说明的是,yumawiki 生成的地图是给 agent 看的辅助工具,不替代项目文档;小仓库里偶尔会有检索噪声(比如工具自己的源码也被索引),但真正的目标文件始终在 top 命中里。把它当成”让 agent 少读代码、少花 token、不被上下文拖累的一张指路图”来用就对了。