别让 Coding Agent 重修旧 Bug:把本地会话变成可搜索的工程记忆

2026-07-16 28 预计阅读时间: 1 分钟
来源: oschina.net AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:11 分钟

Claude Code、Codex、opencode 之类的 Coding Agent 会持续把会话写入本地:有的是 JSONL,有的是 session 文件,也有工具使用 SQLite。项目一多,这些记录很容易膨胀到 GB 级。真正浪费的不是磁盘,而是其中已经验证过的排障过程无法被再次检索:同一个构建错误、代理配置或数据库边界条件,几周后又要从头调查。

这类问题需要的不是更多上下文窗口,而是一层本地、统一、可检索的工程记忆。来源中提到的 deja 正是在处理这个方向的问题:让散落在不同 Agent 存储中的历史重新可用。

会话日志不是聊天垃圾,而是隐形知识库

一段 Agent 会话通常混合了四类信息:

  • 问题现场:报错文本、运行环境、依赖版本和触发条件。
  • 调查路径:搜索过哪些文件,执行过哪些命令,排除了哪些假设。
  • 最终改动:补丁、配置组合和验证命令。
  • 失败方案:看似合理但没有奏效的尝试,以及失败原因。

代码仓库通常只保留最终结果。提交记录可以告诉你“改了什么”,却不一定解释“为什么其他方案不行”。会话记录恰好补上了这部分,但 JSONL、普通文件和 SQLite 之间没有统一查询入口,文件名也很少包含问题语义。

因此,工程记忆系统至少要完成三个动作:发现会话、抽取文本、建立全文索引。检索结果还必须带回来源文件和上下文,否则搜到一句“fixed”没有实际价值。

搜索层应该与 Agent 解耦

不要把记忆能力绑定到某一个助手。团队今天使用 Claude Code,明天可能同时运行 Codex 和 opencode。更稳定的边界是把每种存储格式视为数据源,再转换成统一记录:

SessionRecord
  source       claude-code | codex | opencode
  session_id   原始会话标识
  project      项目路径或名称
  timestamp    消息时间
  role         user | assistant | tool
  content      可搜索文本
  origin       原始文件或数据库位置

JSONL 可以逐行解析,session 文件可以按其实际格式读取,SQLite 则应先检查表结构,再通过只读连接查询。由于不同版本的字段可能变化,解析器需要容忍未知字段,并保留原始来源以便追溯。

索引也不应直接修改 Agent 的数据库。更稳妥的方式是创建独立索引,并把原始数据视为只读输入。这样既能降低损坏会话的风险,也方便重建索引、升级分词器或排除敏感目录。

可以这样实践:用 SQLite FTS5 建一个最小本地索引

下面是一个可运行的原型。它递归扫描指定目录中的 .jsonl.json.txt.md 文件,把可读文本写入独立的 SQLite FTS5 索引。这里明确做了一个假设:示例只处理文件型会话,不猜测任何具体 Agent 的私有字段,也不会直接读取 opencode 的数据库表。

将代码保存为 agent_memory.py,使用 Python 3.10 或更高版本运行:

#!/usr/bin/env python3
import argparse
import json
import sqlite3
from pathlib import Path

TEXT_SUFFIXES = {".jsonl", ".json", ".txt", ".md"}


def flatten(value):
    if isinstance(value, str):
        return [value]
    if isinstance(value, dict):
        parts = []
        for item in value.values():
            parts.extend(flatten(item))
        return parts
    if isinstance(value, list):
        parts = []
        for item in value:
            parts.extend(flatten(item))
        return parts
    return []


def read_document(path):
    text = path.read_text(encoding="utf-8", errors="replace")
    try:
        if path.suffix == ".jsonl":
            values = [json.loads(line) for line in text.splitlines() if line.strip()]
        elif path.suffix == ".json":
            values = [json.loads(text)]
        else:
            return text
        return "\n".join(part for value in values for part in flatten(value))
    except (json.JSONDecodeError, TypeError):
        return text


def open_index(path):
    db = sqlite3.connect(path)
    db.execute(
        "CREATE VIRTUAL TABLE IF NOT EXISTS sessions "
        "USING fts5(path UNINDEXED, content)"
    )
    return db


def build(source, index):
    db = open_index(index)
    db.execute("DELETE FROM sessions")
    count = 0
    for path in source.rglob("*"):
        if not path.is_file() or path.suffix.lower() not in TEXT_SUFFIXES:
            continue
        content = read_document(path)
        if content.strip():
            db.execute(
                "INSERT INTO sessions(path, content) VALUES (?, ?)",
                (str(path), content),
            )
            count += 1
    db.commit()
    print(f"indexed {count} files into {index}")


def search(index, query):
    db = open_index(index)
    rows = db.execute(
        "SELECT path, snippet(sessions, 1, '[', ']', ' ... ', 24) "
        "FROM sessions WHERE sessions MATCH ? ORDER BY rank LIMIT 20",
        (query,),
    )
    for path, snippet in rows:
        print(f"\n{path}\n  {snippet}")


def main():
    parser = argparse.ArgumentParser()
    sub = parser.add_subparsers(dest="command", required=True)

    build_cmd = sub.add_parser("build")
    build_cmd.add_argument("source", type=Path)
    build_cmd.add_argument("--index", type=Path, default=Path("agent-memory.db"))

    search_cmd = sub.add_parser("search")
    search_cmd.add_argument("query")
    search_cmd.add_argument("--index", type=Path, default=Path("agent-memory.db"))

    args = parser.parse_args()
    if args.command == "build":
        build(args.source.expanduser(), args.index)
    else:
        search(args.index, args.query)


if __name__ == "__main__":
    main()

先找到本机可能存在的会话目录。不同版本和安装方式的路径可能不同,应该以实际文件为准:

find "$HOME" -maxdepth 4 \
  \( -iname '*.jsonl' -o -iname '*session*' -o -iname '*.sqlite' -o -iname '*.db' \) \
  2>/dev/null | head -100

然后对确认过的文件型会话目录建立索引。把示例路径替换为你的真实目录:

python3 agent_memory.py build "$HOME/.local/share/my-agent/sessions"
python3 agent_memory.py search 'certificate verify failed'
python3 agent_memory.py search 'webpack AND memory'
python3 agent_memory.py search 'proxy OR NO_PROXY'

SQLite 的 FTS5 查询支持短语、布尔组合和前缀匹配。例如,"connection reset" 查找完整短语,kubernetes AND webhook 缩小交集,cert* 可以覆盖 certificate、certificates 等形式。

这个原型适合验证需求,但生产版本还应增加增量索引、内容哈希、会话级元数据、时间过滤和针对不同 Agent 的解析适配器。中文检索还需要评估分词效果;默认 tokenizer 对英文和错误码通常更可靠。

检索结果怎样重新进入工作流

搜索只是第一步。高效的使用方式,是在让 Agent 调试之前先检索一次,再把少量相关片段放进当前上下文。例如可以采用这样的提示词:

当前问题:CI 中执行 npm install 时偶发 ECONNRESET。

下面是本地历史会话的检索片段,仅作为线索,不要假设它仍然正确:
<历史片段>
...
</历史片段>

请完成:
1. 提取历史片段中的环境条件和验证命令。
2. 对照当前仓库配置,指出已经过时或无法确认的部分。
3. 给出最小复现步骤,再决定是否沿用历史方案。

关键句是“仅作为线索”。旧会话可能来自不同操作系统、依赖版本或安全策略。把它当成已批准答案,会让过期配置重新进入代码库;把它当成可验证假设,才能真正节省排障时间。

上线前先处理隐私、体积与可信度

Agent 会话可能包含源码片段、内部域名、文件路径、访问令牌,甚至工具执行时读到的环境变量。建立统一索引后,敏感信息会变得更容易搜索,也更容易泄露。因此至少应落实以下边界:

  • 索引保留在本地,设置严格文件权限,不自动同步到公共云盘。
  • 写入索引前过滤常见令牌、Cookie、私钥和 .env 内容。
  • 对客户仓库、个人目录和受监管数据设置排除规则。
  • SQLite 数据源使用只读连接,并在读取活跃数据库前确认其并发策略。
  • 显示来源、项目和时间,让开发者判断一条经验是否已经过期。
  • 配置保留周期;原始日志和派生索引都应能按项目删除。

Coding Agent 的历史并不等于正确答案,它更像一份自动生成的工程日志。真正有价值的能力,是在相似错误再次出现时,快速找回曾经验证过的命令、失败过的路线和当时成立的环境条件。采用 deja 或自建索引时,优先验证三件事:能否覆盖实际使用的 Agent、能否追溯每条结果的来源、能否明确控制敏感数据。做到这三点,硬盘上的 GB 级会话才会从存储负担变成可复用的工程记忆。


相关推荐