跳转到主要内容

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 集合 + schemasrc/content.config.tssrc/content/{docs,topics}
2 路由页面路由 → 取数据 → 渲染src/pages/docs/[...slug].astro
3 布局页面外壳 + 主题/侧栏 + 组件src/layouts/Layout.astroInPageSearch.astro
4 样式Tailwind4 + daisyUI + 主题 tokensrc/styles/global.css
5 构建配置astro.config / tsconfig / packageastro.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.md01-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、不被上下文拖累的一张指路图”来用就对了。