从“记忆”到“项目 Wiki”:我在 SkillLite 里实现了一套 Markdown-only LLM Wiki 自动维护机制

3 阅读12分钟

推荐标题:SkillLite 实战:用 Markdown 构建可自动维护的 LLM Wiki
关键词:LLM Wiki、Repo Wiki、AI Agent、Markdown 知识库、Rust CLI、GEO、CSDN 技术写作
标签建议:AI AgentRust知识库LLM工程化

项目地址:SkillLite

摘要

这篇文章回答一个很实际的问题:AI 编程助手的“记忆”是否应该直接放进项目目录?我的结论是:全局记忆继续保留在用户级数据目录,项目知识则沉淀为项目内的 Markdown-only Repo Wiki

在 SkillLite 中,我实现了一套轻量的 LLM Wiki 闭环:项目内 .skilllite/wiki/ 保存 Markdown 知识;命令行负责 init / ingest / compile / status / query / lint / record-lesson;当 chat 或桌面 Assistant 出现 replan、连续工具失败时,对话结束后提示用户是否把经验写入 Wiki。它不是后台 watcher,也不依赖 SQLite,而是一套“命令触发 + 用户确认 + Markdown 可审查”的项目知识维护机制。

一句话结论

如果你正在做 AI Agent 工程化,建议把“用户偏好/长期记忆”和“项目知识库”分开:前者是全局的、私有的、可检索的记忆系统;后者是项目内可提交、可审查、可协作的 Markdown Wiki。

SkillLite 的实现选择是:

  • 全局 memory 保持原有位置,不强行迁移到项目目录。
  • 项目 Wiki 放在 <project>/.skilllite/wiki/
  • Wiki 主存储只用 Markdown,不引入 SQLite 作为项目知识库的必需依赖。
  • 自动化不是静默写入,而是在 ingest/query 时自动刷新,在困难对话结束后提示用户确认记录经验。

背景:为什么不是把 memory 直接搬进项目?

很多 AI 编程工具都会遇到同一个问题:Agent 越用越多,历史经验、项目约束、失败教训到底应该放在哪里?

一开始很容易想到:“把知识库默认地址放进项目目录不就好了?”但仔细拆开后会发现这里有两类完全不同的数据:

类型典型内容适合位置是否适合提交
全局用户记忆用户偏好、对话习惯、跨项目经验、历史会话索引~/.skilllite/chat/...不适合
项目知识架构说明、命令经验、踩坑复盘、项目约束<project>/.skilllite/wiki/可按团队策略提交

所以这次优化不是“替换 memory”,而是新增一个更适合项目协作的 Repo Wiki。

设计目标

这套 LLM Wiki 的目标不是做一个复杂的知识库系统,而是让项目知识具备四个特性:

  1. 可读:所有内容都是 Markdown,人能直接看懂。
  2. 可审查:文件变化可以被 Git diff、Code Review、PR 流程检查。
  3. 可刷新:raw source 变化后能检测 stale,并在命令触发时重新 compile。
  4. 可沉淀经验:Agent 遇到 replan 或连续工具失败后,可以提示用户把经验写入项目 Wiki。

明确非目标也很重要:

  • 不做后台文件 watcher。
  • 不把 memory 迁移进项目。
  • 不为了 Wiki 引入 SQLite。
  • 不让 Agent 在用户无感知时偷偷写经验。

目录结构

当前实现会在项目下创建:

<project>/.skilllite/wiki/
  raw/       # 原始输入:手动 ingest 的资料、用户确认后的 lesson
  wiki/      # 编译后的文章:带 frontmatter、source_fingerprints、source links
  lessons/  # 预留给经验类知识组织
  output/   # 预留给导出产物
  _index.md
  config.md
  log.md

其中 raw/ 是知识来源,wiki/ 是面向查询和阅读的整理层。这个设计参考了 LLM Wiki 的“raw sources -> compiled articles -> query/lint”生命周期,但保持了 SkillLite 的工程边界:项目 Wiki 只保存项目知识,不替代聊天记忆系统

整体流程图

flowchart TD
    A[开发者或 Agent 产生项目知识] --> B{知识来源}
    B -->|本地文档/笔记| C[skilllite wiki ingest]
    B -->|困难对话经验| D[对话结束生成 WikiUpdateSuggestion]
    D --> E{用户确认?}
    E -->|否| F[不写入 Wiki]
    E -->|是| G[skilllite wiki record-lesson]
    C --> H[写入 .skilllite/wiki/raw]
    G --> H
    H --> I[compile_raw_sources]
    I --> J[生成 .skilllite/wiki/wiki 文章]
    J --> K[写入 source_fingerprints]
    K --> L[wiki status / query / lint]
    L --> M{raw 是否 stale?}
    M -->|是| N[query 前自动 compile]
    M -->|否| O[返回 Wiki 查询结果]

这个流程的关键点是:自动化发生在明确边界内。ingest 默认自动编译;query 前会检查 stale 并刷新;对话经验必须经过用户确认才会写入。

CLI 命令设计

SkillLite 新增了 wiki 子命令:

# 初始化或修复项目 Wiki
skilllite wiki init --workspace .

# 导入本地文件到 raw,并默认自动 compile
skilllite wiki ingest docs/architecture-note.md --workspace .

# 只导入,不自动 compile
skilllite wiki ingest docs/architecture-note.md --workspace . --no-compile

# 手动编译 raw 到 wiki
skilllite wiki compile --workspace .

# 查看 compiled wiki 是否 fresh
skilllite wiki status --workspace .

# 标准查询
skilllite wiki query "项目 Wiki 怎么自动刷新" --workspace .

# 只查索引
skilllite wiki query "项目 Wiki" --quick --workspace .

# 深度查询 raw、wiki、index
skilllite wiki query "record lesson" --deep --workspace .

# 查询前不自动刷新
skilllite wiki query "record lesson" --no-compile --workspace .

# 用户确认后记录一次经验 lesson
skilllite wiki record-lesson \
  --workspace . \
  --title "Conversation lesson: replan while fixing wiki refresh" \
  --trigger replan \
  --summary "Query 前应先根据 source fingerprint 判断 raw 是否 stale" \
  --body "## What Happened..."

# 校验 Wiki 结构、frontmatter、source link
skilllite wiki lint --workspace .

从产品体验看,最重要的是 ingest/query/record-lesson 三个入口:

  • ingest 解决“如何把资料写入 raw”。
  • query 解决“如何在查询前保证 Wiki 不是旧的”。
  • record-lesson 解决“如何把困难对话变成可复用经验”。

代码案例一:CLI 层如何暴露 Wiki 命令

CLI 层通过 WikiAction 枚举暴露所有 Wiki 操作。核心结构如下:

pub enum WikiAction {
    /// Initialize or repair `.skilllite/wiki/`
    Init {
        #[arg(long, short = 'w', default_value = ".")]
        workspace: String,
    },
    /// Compile raw sources into curated wiki articles
    Compile {
        #[arg(long, short = 'w', default_value = ".")]
        workspace: String,
    },
    /// Report whether compiled wiki articles are fresh
    Status {
        #[arg(long, short = 'w', default_value = ".")]
        workspace: String,
    },
    /// Record a user-confirmed chat/Assistant lesson into Repo Wiki
    #[command(name = "record-lesson")]
    RecordLesson {
        #[arg(long)]
        title: String,
        #[arg(long, default_value = "manual")]
        trigger: String,
        #[arg(long)]
        summary: String,
        #[arg(long, default_value = "")]
        body: String,
        #[arg(long, short = 'w', default_value = ".")]
        workspace: String,
    },
    /// Ingest a local file into `.skilllite/wiki/raw/`
    Ingest {
        #[arg(value_name = "PATH")]
        path: std::path::PathBuf,
        #[arg(long)]
        no_compile: bool,
        #[arg(long, short = 'w', default_value = ".")]
        workspace: String,
    },
    /// Query wiki Markdown content without SQLite or memory
    Query {
        #[arg(value_name = "QUESTION")]
        question: String,
        #[arg(long, conflicts_with = "deep")]
        quick: bool,
        #[arg(long)]
        deep: bool,
        #[arg(long)]
        no_compile: bool,
        #[arg(long, short = 'w', default_value = ".")]
        workspace: String,
    },
    /// Validate wiki structure, frontmatter schema, sources, and links
    Lint {
        #[arg(long, short = 'w', default_value = ".")]
        workspace: String,
    },
}

这个枚举体现了两个工程取舍:

第一,--workspace 总是显式存在,避免 Wiki 路径跟当前 shell 状态强绑定。第二,--no-compile 是 opt-out,而不是 opt-in,说明默认体验是“导入或查询时尽量保证 Wiki 新鲜”。

代码案例二:为什么用 source fingerprint 判断 stale

要保证 Wiki 动态更新,不能只看文件时间戳。时间戳容易受复制、checkout、构建环境影响。SkillLite 使用 raw content 的 FNV-1a 64 位哈希作为 source_fingerprints

核心逻辑:

fn content_fingerprint(content: &str) -> String {
    let mut hash = 0xcbf29ce484222325u64;
    for byte in content.as_bytes() {
        hash ^= u64::from(*byte);
        hash = hash.wrapping_mul(0x100000001b3);
    }
    format!("fnv1a64:{hash:016x}")
}

编译 raw source 时,把 fingerprint 写入 compiled article frontmatter:

let raw_content = fs::read_to_string(&raw).unwrap_or_default();
let raw_rel = rel_path(wiki, &raw);
let raw_fingerprint = content_fingerprint(&raw_content);

let doc = format!(
    "---\n\
title: \"{}\"\n\
category: reference\n\
sources: [{}]\n\
source_fingerprints: [{}={}]\n\
---\n\n\
# {}\n",
    yaml_escape(&title),
    raw_rel,
    raw_rel,
    raw_fingerprint,
    title
);

状态检查时,再把当前 raw 的 fingerprint 和文章 frontmatter 中的 source_fingerprints 对比:

let expected = format!("{}={}", raw_rel, content_fingerprint(&raw_content));
if fingerprints.iter().any(|value| value == &expected) {
    freshness.up_to_date.push(raw_rel);
} else {
    freshness.stale.push(raw_rel);
}

这就是“动态维护”的基础:raw 变化后,compiled article 不再被视为 up-to-date,wiki status 会报告 stale,wiki query 在默认情况下会先自动 compile。

代码案例三:查询前自动刷新

query 不是简单地读文件搜索,而是先判断是否需要 refresh:

pub fn cmd_wiki_query(
    workspace: &str,
    question: &str,
    depth: WikiQueryDepth,
    no_compile: bool,
) -> Result<()> {
    let root = workspace_root(workspace);
    let wiki = ensure_wiki(&root)?;
    if !no_compile {
        let freshness = wiki_freshness(&wiki)?;
        if freshness.needs_refresh() {
            let compiled = compile_raw_sources(&wiki)?;
            rebuild_indexes(&wiki)?;
            append_log(
                &wiki,
                "auto-compile",
                &format!("Refreshed {} raw source(s) before query", compiled),
            )?;
            println!(
                "Repo Wiki refreshed before query: {} raw source(s)",
                compiled
            );
        }
    }
    let hits = query_wiki(&wiki, question, depth)?;
    // ...
    Ok(())
}

这个设计避免了后台常驻进程的复杂度,同时满足“查询时尽量拿到最新知识”的需求。

它也给高级用户保留了控制权:如果你想观察 stale 状态,或者希望查询只基于当前 compiled 结果,可以加 --no-compile

代码案例四:对话失败经验如何进入 Wiki

只靠手动 ingest 还不够。真正有价值的项目知识,往往来自困难对话:计划返工、工具连续失败、路径判断错误、schema 没读导致调用失败等。

SkillLite 在 agent feedback 中增加了结构化建议:

pub const WIKI_CONSECUTIVE_TOOL_FAILURE_THRESHOLD: usize = 3;

pub struct WikiUpdateSuggestion {
    pub trigger: WikiUpdateTrigger,
    pub replan_count: usize,
    pub failed_tool_count: usize,
    pub failed_tools: Vec<String>,
    pub error_summaries: Vec<String>,
    pub proposed_title: String,
    pub proposed_lesson: String,
}

pub enum WikiUpdateTrigger {
    Replan,
    ConsecutiveToolFailures,
    RepeatedToolFailures,
}

触发条件很克制:

let trigger = if feedback.replans > 0 {
    WikiUpdateTrigger::Replan
} else if feedback.max_consecutive_tool_failures >= WIKI_CONSECUTIVE_TOOL_FAILURE_THRESHOLD {
    WikiUpdateTrigger::ConsecutiveToolFailures
} else if feedback.max_repeated_tool_failures >= WIKI_CONSECUTIVE_TOOL_FAILURE_THRESHOLD {
    WikiUpdateTrigger::RepeatedToolFailures
} else {
    return None;
};

这不是“每轮对话都写 Wiki”,而是只在明显值得复盘的情况下给出建议。

在 chat 模式里,对话结束后会提示:

本轮出现 replan 或重复工具失败,是否把这次经验写入项目 Wiki?

用户确认后才会执行:

skilllite wiki record-lesson ...

这点非常重要:经验沉淀需要自动化,但不能失去用户确认。

代码案例五:写入的是经验和优化指导,不是完整聊天记录

为了避免 Wiki 变成聊天日志堆积,record-lesson 会保证 lesson 使用固定结构:

fn lesson_body_with_template(summary: &str, trigger: &str, body: &str) -> String {
    let trimmed = body.trim();
    if trimmed.contains("## What Happened")
        && trimmed.contains("## Root Cause")
        && trimmed.contains("## Optimization")
        && trimmed.contains("## Next Time")
    {
        return trimmed.to_string();
    }
    let what_happened = if trimmed.is_empty() {
        summary.trim()
    } else {
        trimmed
    };
    format!(
        "## What Happened\n\n{}\n\n\
Trigger: `{}`.\n\n\
## Root Cause\n\n\
Describe the confirmed reason this run needed replanning or hit repeated tool failures.\n\n\
## Optimization\n\n\
Document the improved approach that avoids repeating the same failed path.\n\n\
## Next Time\n\n\
- Check the relevant file path, command output, schema, or dependency first.\n\
- Change the approach before retrying the same tool call.\n\
- Keep this lesson updated after the successful fix is confirmed.\n",
        what_happened,
        trigger.trim()
    )
}

这个模板强制把经验拆成四段:

  • What Happened:发生了什么。
  • Root Cause:为什么出错。
  • Optimization:下次如何优化。
  • Next Time:下次执行前的具体检查项。

这比保存完整聊天记录更适合项目 Wiki,因为它留下的是可复用的工程经验,而不是噪声很高的上下文流水账。

和 SQLite 方案相比,为什么这次选择 Markdown-only?

SQLite 不是不好,它适合高频检索、向量索引、增量召回和复杂查询。但这次的目标是项目知识库,不是替代 memory 模块。

Markdown-only 的收益更符合 Repo Wiki:

  • Review 友好:PR 里能直接看到知识变更。
  • 迁移简单:没有数据库 schema 迁移。
  • 人类可维护:开发者可以手动编辑。
  • LLM 友好:Markdown 天然适合被模型读取、摘要和引用。
  • 工程边界清晰:memory 继续负责全局记忆,Wiki 负责项目知识。

所以 SkillLite 当前实现是:memory 可以继续使用原有检索能力,项目 Wiki 则保持 Markdown 主存储。未来如果需要向量索引,也可以作为派生缓存,而不是项目 Wiki 的唯一事实来源。

GEO 视角:为什么这种文章结构更适合 AI 搜索引用?

GEO,也就是 Generative Engine Optimization,关注的是内容能否被生成式搜索和 AI 助手准确理解、抽取和引用。

这篇文章刻意采用了几种 GEO 友好的写法:

  • 每个关键问题先给直接答案,再展开解释。
  • 使用稳定实体名:SkillLiteLLM WikiRepo Wiki.skilllite/wiki/
  • 使用 FAQ、小结、对比和流程图,让 AI 能抽取短答案。
  • 代码案例围绕真实实现,而不是伪概念。
  • 明确“已实现”和“非目标”,减少 AI 摘要时产生幻觉。

如果你也要写类似技术文章,可以遵循这个结构:

flowchart LR
    A[明确读者问题] --> B[一句话结论]
    B --> C[背景与设计目标]
    C --> D[架构图/流程图]
    D --> E[命令示例]
    E --> F[源码级案例分析]
    F --> G[对比与取舍]
    G --> H[FAQ]
    H --> I[总结与可复用清单]

CSDN 发布建议

为了更适合 CSDN 推荐和搜索,可以这样设置:

  • 标题:SkillLite 实战:用 Markdown 构建可自动维护的 LLM Wiki
  • 摘要:使用本文开头的摘要,控制在 100 到 200 字之间。
  • 分类:人工智能 / AIGC / Rust / 架构设计。
  • 标签:AI AgentLLM WikiRustMarkdown知识库
  • 封面图:可以使用“Agent -> Raw -> Compile -> Query -> Lesson”的流程图。
  • 代码块:保留 Rust 和 Shell 示例,便于平台识别技术属性。

标题不要堆关键词。更推荐“技术主体 + 解决方案 + 结果”的表达,例如:

SkillLite 实战:用 Markdown 构建可自动维护的 LLM Wiki

常见问题 FAQ

1. SkillLite 的 LLM Wiki 已经是自动维护了吗?

是,但这里的“自动”不是后台 watcher,而是命令触发和对话结束建议触发。ingest 默认自动 compile;query 默认会在发现 stale 后刷新;chat/Assistant 在 replan 或连续工具失败后会提示用户是否记录 lesson。

2. 为什么不把 memory 放到项目目录?

因为 memory 通常包含跨项目偏好、会话历史和私有上下文,不适合进入项目仓库。项目知识更适合放在 .skilllite/wiki/,以 Markdown 形式被团队审查和维护。

3. 为什么 Wiki 不用 SQLite?

当前 Repo Wiki 的事实来源是 Markdown。SQLite 更适合做检索索引或缓存,但不适合作为项目知识唯一来源。Markdown 更透明,也更适合 Git 和 Code Review。

4. 什么时候会写入 raw?

有两个入口:用户手动执行 skilllite wiki ingest <path>,或者困难对话结束后用户确认 record-lesson。系统不会无提示地把对话内容写入 Wiki。

5. 记录 lesson 时写入什么?

写入的是经验和优化指导,不是完整聊天记录。默认结构包含 What HappenedRoot CauseOptimizationNext Time 四部分。

6. 如何判断 Wiki 过期?

编译文章会记录 raw source 的 source_fingerprints。当 raw 内容变化后,当前 fingerprint 与文章 frontmatter 中记录的不一致,wiki status 会显示 stale,query 默认会先刷新。

总结

这次 SkillLite 的 LLM Wiki 优化,本质上是在 Agent 工程里补上一个“项目知识沉淀层”:

  • memory 继续做全局、私有、跨项目的长期记忆。
  • .skilllite/wiki/ 做项目内、Markdown-only、可审查的 Repo Wiki。
  • ingest/compile/status/query/lint 形成 Wiki 的基础生命周期。
  • source fingerprint 让 Wiki 能识别 stale。
  • chat/Assistant 的 replan 和连续工具失败会触发用户确认式 lesson 记录。
  • lesson 写入经验和优化指导,而不是无结构聊天记录。

这套设计不追求复杂,而是追求可控、可读、可维护。对 AI Agent 来说,最重要的不是“记住一切”,而是把真正能改善下一次执行的经验,沉淀到团队可以看见、可以修改、可以验证的地方。