
先交代一下背景。今年初,我开始做一个叫「记了么」(EverLingo)的开源项目。到今天为止的一些数字:
- 开发周期:74 天
- Commit 数:460+
- 代码量:
src/约 1.9 万行 Python,另有 React 前端和 Chrome 扩展 - 测试:约 1150 个用例,2 万行测试代码——比源码还多
- 发布:从 v0.0.1-rc.1 到 v0.1.3,20+ 个 tag
团队编制是——我,和一个 agent。
整个项目是我第一次用一个 Coding Agent(OpenCode)以"正式项目"的标准开发的。不是 demo,不是 weekend project,是要部署给家人与朋友用的开源项目。
这篇文章复盘三件事:
- 第一次和 Coding Agent 长期协作,我的工作流是怎么长出来的(文档、图、Skill);
- 项目本身就是一个 AI Agent Harness——里面的多 Agent 架构是怎么进化、怎么踩坑的;
- 产品是怎么从一个微信小 bot 长成多用户容器化产品的。
写在前面:花两分钟认识「记了么」
后文会反复出现一些产品和架构概念,这里用一个小场景把它们交代清楚。
想象你读到一个英文网页,里面有个词 “structural” 不确定在语境里的意思。你在微信里跟小记(产品的化身是一只仓鼠 🐹)聊天。你把整段话复制过去问它。小记结合上下文给你解释——因为你之前告诉它,你是程序员,它会顺手用工程上的例子来讲。然后它告诉你:「已帮你记下了这个知识点。」
此时后台发生了一串你看不见的事:这次查询的原文段落、来源 URL、你的提问被组装成一条记忆条目,交给一个后台 Agent,由它调用 LLM 把新知识和笔记库里已有的内容合并成一篇 markdown 笔记;笔记库自动重建全文索引和向量索引,并定时 git 提交备份。三周后你忘了这个词,回来问小记「之前记过 structural 吗」,它能从笔记库里检索出来,还知道你当时是在哪篇文章里遇到它的。
这就是产品的核心循环:查询 → 回答 → 后台沉淀记忆 → 越用越懂你。传统词典工具的流程是「查询 → 得到答案 → 结束」,查询行为什么都没留下;「记了么」认为查过不等于记住,查询应该有后半场。
文中会出现的概念,一张表说完:
| 概念 | 是什么 |
|---|---|
| Chat Agent(小记) | 主对话 Agent,负责理解意图、回答问题、判断哪些知识值得记录 |
| Channel(渠道) | 接入适配层,把微信、Web、Chrome 扩展、终端的消息统一成相同格式 |
| Session / Gateway | 一次对话的载体和它的管理者;Session 内部是一个事件队列 |
| Memory Writer Agent | 后台写笔记的 Agent,daemon 线程异步消费记忆条目,不阻塞聊天 |
| vault(记忆库) | 用户的 markdown 笔记库,带 git 版本控制、全文 + 向量检索 |
| Indexer / MCP | 独立的索引进程,通过 MCP 协议把"读写笔记库"以标准工具形式提供给 Agent |
| Envelope | 用户输入的结构化信封:选中的词、上下文段落、来源 URL、期望任务 |
| ADR | 架构决策记录,每次重大取舍写成文档存档 |
记住一幅最小画面就够了:两个 LLM Agent——Chat Agent 在台前说话,Memory Writer 在幕后写字——共享同一个用户的 markdown 记忆库。 后文讲的,就是这幅画面是怎么一步步进化出来的。
一、第一次和 Coding Agent 合作写正式项目
1.1 起步:仓库里只有文档
项目的第一个 commit(e015fb1 "init")里面没有任何代码,只有 153 行文档:PRODUCT.md 和一份 phase 0.1 的设计规格。
这不是刻意的行为艺术,而是被逼出来的直觉:面对一个不会自己读心的 Agent,你唯一能做的就是把它需要的上下文准备好。紧接着几天的 commit message 很诚实地记录了这个过程:
271a0df day1
6d8fcb6 day2 before first gen
e384f47 before 意图分析Agent 化
“before first gen”、“before 意图分析 Agent 化”——先写规格,再让 AI 生成。这个习惯一直保持到了最后。74 天后回头看,整个项目其实是三层东西的组合:
- 给 Agent 看的文档(规格、ADR、风格指南)
- 给 Agent 立的规矩(AGENTS.md 的强制闭环)
- Agent 写出来的代码
1.2 文档怎么喂:分层 + 强制回路
一开始我觉得反正有 agent 写代码,文档随便记记就行。很快发现行不通:agent 每次开新会话都是失忆状态,文档就是它的记忆。更麻烦的是,AI 时代的文档腐烂速度比人工开发快好几倍——代码生成得太快了,文档一跟不上,下一轮 Agent 就会基于过期认知继续生成,错误开始滚雪球。
两个月里迭代了几版后,文档体系稳定成这个分层:
AGENTS.md # agent 工作守则:测试命令、代码风格、协作规则入口
PRODUCT.md # 产品是什么、卖给谁、核心能力
DOMAIN.md # 领域概念表:我和 agent 对同一批名词的唯一共识
ARCHITECTURE.md # 架构单一入口:全貌图 + 组件表 + 全部设计文档导航
docs/impl-spec/** # 实现规范,按域分目录(agents/ channels/ vision/ ...)
docs/ADR/** # 架构决策记录
docs/archived/** # 废弃设计的归档,不删除,留溯源
TASKS.md # 工作日志:改了什么、为什么、怎么验证的
几条实战心得。
DOMAIN.md 的克制。“只写最终用户可感知对象"这条纪律,是为了防止文档膨胀——内部实现细节一律下沉到 impl-spec,领域文档永远保持薄。Agent 的注意力是稀缺资源,每一份文档都在和其他文档抢 token。
AGENTS.md 的强制闭环。光有文档不够,还得让 Agent 有义务维护它们。我在 AGENTS.md 里立了几条规矩:
|
|
这些规则的本质是把"人肉 Code Review"升级成了"人肉 Doc Review”——我不逐行看代码,但我盯住文档和实现的偏差。偏差一旦出现,要么代码错了,要么文档该更新了,两个都需要我知道。
ADR 是最划算的投资。每次 “要不要删掉某个 Agent”、“要不要换协议” 级别的讨论,我都让 opencode 把动机、备选方案、风险和缓解写成 ADR。当时看不出收益,两周后新会话里的 agent 读一遍就完全恢复决策上下文,不用我复述。举一个 ADR 里的风险表实例(摘自移除 Memory Extract Agent 的那份,后面第二节会讲到的故事):
| 风险 | 缓解 |
|---|---|
| Chat Agent 过度抽取 | 跳过规则迁移到 system prompt;枚举值用 Literal 锁定 |
| LLM 编造系统字段 | args schema 不暴露系统字段,系统字段由代码补全 |
| LLM 跳过 read spec 直接调工具 | system prompt 强约束 + 工具 description 提示;测试观察遵守度 |
写这份表格的过程本身就是设计 review。很多"想当然可行"的方案是在填这张表时被毙掉的。
废弃的文档要归档,不要删除。git 历史能告诉你什么时候删的,归档文档能告诉你为什么当初存在。Agent 有时候会提出一个几个月前就被否决过的方案,有了 docs/archived/,一句"那个方案因为 XX 原因废了,见归档"就能省一轮重新论证。(第二节就有活例子。)
TASKS.md 当工作日志用。每次改完源码让 agent 追加一条记录,格式强制:日期时间、干了什么、怎么验证的。看起来繁琐,但 git log 是以 commit 为单位的流水,TASKS.md 是以任务为单位的叙事——排查问题时后者好使得多。
8 月下旬我做了一次整体的"文档体检",还顺手发现了一个 49 行的 ARCHITECTURE.md——早期它几乎是个纯链接列表,Indexer 进程、多用户架构这种一级主体完全缺席。重写之后它才配叫"架构单一入口"。
1.3 图即代码:从 draw.io 到 d2 / mermaid
这是我体会最深的一次工具切换。
7 月上旬,我用 draw.io 画了几张架构图,导出 SVG 放进仓库。人的体验很好,但用了十来天发现一个尴尬的事实:这张图只有我能看,我的 agent 看不见。
具体的痛点有三个:
- agent 读不了 draw.io 源文件。我没法对它说"对照架构图检查一下这个模块的文档",图对它是黑盒;
- 架构变了之后,更新图是我的活儿。agent 改完代码经常忘了提醒我图已经过时;
- SVG 没法走 git diff,review 时看不出一张图到底改了哪根线。
于是那张图停在 7 月中,之后一个多月无人维护——不是不想维护,是打开 GUI 手工对齐图形的成本太高,而代码每天都在变。
转机出现在 8 月底做文档重构的时候。我在 ADR 里写下这条决策:
架构图一律采用 markdown 内嵌 d2 图文本,经 d2 CLI 编译验证。
d2 是一种用纯文本描述图的 DSL,直接内嵌在 markdown 里。背后的逻辑很简单:Coding Agent 能写文本,画不了 GUI。迁移之后的收益是实打实的:
- agent 能读也能写。“根据最新的 gateway 代码更新这张 d2 图"是可以直接下的指令;
- 文本格式走 git diff,加一根线就是一行变更;
d2CLI 可以编译验证,语法错了 CI 都能拦住;- 文档和代码在同一处评审,过时问题大大缓解。
效果立竿见影:切换当天,我让 Agent 一次性给 28 个 spec 补上了图,并重写了 ARCHITECTURE.md 内嵌三张全景图。这种事在 draw.io 时代想都不敢想。
经验最后收敛成一条法则:结构图(谁连着谁)用 d2,流程图(时序、状态流转)用 mermaid——d2 的 elk 布局画拓扑更舒服,mermaid 画流程最顺手,且 GitHub 原生渲染。这不是审美偏好:当你有一个随时在读你仓库的协作者时,“机器可读"就从加分项变成了必选项。
举个真实的例子,这是现在 ARCHITECTURE.md 里的核心数据流图(对话与记忆沉淀):
这张图本身就是文章主题的证据:它是 Agent 维护的,跟着代码一起活在仓库里。顺带一提,本文里的所有图也都是这么画的。吃自己的狗粮。
1.4 Skill:把 SOP 固化进仓库
重复超过两次的流程,做成 skill——本质上是放在 .agents/skills/ 下的一段指令文档,agent 在匹配场景下自动加载。
最实在的例子是发版。这个项目发版极频(20+ 个 tag),发一次版要动的地方多得吓人:README 双语、部署文档、__init__.py、前端页面、pyproject.toml,还有一堆服务的版本号常量……其中有一条踩坑换来的教训被原样固化进了 skill 文档:
Chrome 扩展的
manifest.json的version字段仅支持 1~4 个点分隔整数,不支持 semver 预发布后缀。任何带连字符的版本号在上传 Web Store 时都会报Invalid value for 'version'而拒绝加载。
因此0.1.2-rc.4必须转写成"version": "0.1.2.4"写入 manifest,而 package.json 保留原 semver。
这种步骤清单靠人脑记必然漏。写成 skill 后,每次发版就是一句 /releasing,agent 自动执行整套流程并处理这类特例。
另一个 skill 更生活化:round-corners-of-images,给定 markdown 文件批量把引用的图片做圆角处理——为写博客准备的。
两个 skill 是同一个模式:skill 的价值不在省那几分钟,在于把"我记得要做"变成"仓库记得要做”。人的记忆力不可靠,仓库可靠。
二、项目本身的 Agent Harness 进化史
如果说第一部分是"怎么用 Agent 开发”,这一部分是"怎么开发 Agent"。Everlingo 的主技术栈本身就是一个 AI Agent Harness:前台一个带工具的 Chat Agent,后台一套自动运转的记忆系统。
这部分按时间顺序讲几次大的架构变化。每次都写清楚:为什么改、怎么改的、踩了什么坑。
先上现在的全景架构图:
两个进程、两个 LLM Agent、一套基于 MCP 的存储检索。看起来挺清爽,但它不是一开始就这样的。
2.1 早期:chat.py 走天下,然后碎掉
第一天的东西毫无悬念。单文件 chat.py,调一次 LLM,打印回复。
第一个真正的需求把它压垮:用户一会儿查词一会儿闲聊,得分意图。规则判断很快写不动了,改成让 LLM 自己判断意图分支——这也是第一次体会到"LLM 做判断、代码做路由"的模式。
紧接着微信接入来了。消息收发的线程模型和 LLM 调用的异步模型搅在同一个文件里,chat.py 变成了谁都看不懂的东西。于是有了第一次像样的重构:Gateway / Session / Agent / Channel 四个抽象各就各位——Channel 只管平台收发细节,Session 把一个用户的一次对话串起来,Agent 只管思考。
这次重构之后我定了一个流程,一直沿用到最后:每次结构性改动,agent 必须同步更新对应设计文档并跑完全部单测,才算完成。 文档先行、测试兜底,后面的每次大改才敢下手。
2.2 三段流水线的诞生,以及 Extract Agent 的退场
产品核心能力"自动记笔记"上线时(6 月底),设计了教科书式的三段流水线:Chat Agent 负责对话,一个独立的 Memory Extract Agent(daemon 线程 + queue + LLM structured output)负责"判断这轮对话里有没有值得记的知识点"并抽取出结构化条目,Memory Writer Agent 再把这些条目合并写成 markdown 笔记。
教科书式的多 Agent 分工,对吧?现实很快教做人。
坑一:headword 去重失效。 旧设计每轮都把最近 20 轮对话整体交给 LLM 抽取,会话内去重靠一个 session_seen_headwords 字符串集合匹配。问题是 headword 由 LLM 生成,即使 temperature=0 也无法保证逐字一致——同一段历史被反复抽取且两次抽出的标题不一致,去重形同虚设。修复方式是输入侧硬隔离:把消息切成 new_messages(本轮新增,唯一抽取来源)和 context_messages(仅作背景),从根上杜绝重复扫描。
坑二:temperature=0.7 导致字段漂移。 最初 Extract Agent 复用了聊天的 LLM 配置。抽取任务要求结构化、确定性输出,0.7 的温度会带来字段漂移。只好为它单开一个 LLM 工厂,temperature 锁死为 0。
这两坑的共同教训:凡是想让 LLM 输出保持精确一致的地方,都会失望。去重要在输入侧用代码切干净,事实字段由代码补全,而不是指望 LLM 两次给出同一个词。
然后一个月里,Extract Agent 的职责被一块块搬走了:
- “是否值得抽取"的判断移给了 Chat Agent——它有最完整的对话上下文;
- 输入隔离改为游标切片,Extract 只消费切好的数据;
- 结构化输出瘦身到最后只剩三个字段,其余系统字段全部由代码补全。
砍到最后我盯着它看了很久:这个 Agent 剩下的全部工作,是对 Chat Agent 刚产出的内容再做一次 LLM 调用,把同样的语义重新格式化一遍。同源数据、重复判断、多一次成本、多一个失败点、多一组数据结构和测试。收益呢?趋近于零。
于是有了项目第一批 ADR 之一:《移除 Memory Extract Agent》。新形态下,Chat Agent 通过一个 request_memory_extraction(entries=[...]) 工具在对话中直接声明"这段值得记”,drafts 在内存累积,invoke() 结束时由代码补全系统字段后入队给 Writer。LLM 可能编造的字段用 pydantic Literal 枚举锁死:
|
|
注意系统字段(entry_id、timestamp、消息切片)不在 schema 里——不由 LLM 编造,由工具体在代码里补全。
这次重构确立了一条原则,后来反复被验证:每当准备新增一个 Agent,先问一句——它的职责能不能变成现有 Agent 的一个工具调用? Agent 不是普通的类,每多一个就多一份调度、状态和失败的复杂度,默认答案应该是"不能"。同一条原则在这个项目里到处都是:
- events 日志追加不走 LLM(“性价比很低,且增加幻觉/格式错误风险”,纯代码拼装);
- entry_id、timestamp 永远代码生成,不让 LLM 碰;
- 系统字段用
Literal枚举兜底防编造; - 后面会讲到的 Vision Service 只做感知不做解题,也是同一逻辑。
顺便说,Extract Agent 的 spec 我没有删,它在 docs/archived/ 里躺着。一个月后 Agent 有次提议恢复独立的抽取 Agent,我直接甩了个归档链接过去。
2.3 NoticeSink:后台线程如何举手发言
流水线异步化之后,冒出一个新的体验问题——Writer 写完笔记之后,没人知道。
具体来说:Memory Writer 是个全局单例 daemon 线程,异步消费队列写 vault;而每个聊天 Session 绑定在 asyncio event loop 上。Writer 辛辛苦苦写完一篇笔记,聊天窗口一片安静,用户根本不知道小记刚才帮他记了一条。“帮你记下了 xxx"这句话必须从后台线程送回前台的会话流。
听起来简单,动手才发现是经典的跨线程通信问题,而且有个特殊约束:通知不能无脑转发给用户——有些操作值得告知有些是噪音,判断需要理解上下文,又是 LLM 的活儿。
解法是引入 NoticeSink Protocol 和 SystemNotice 事件(伴随一个大 commit:Session 重构为统一事件队列模式)。Protocol 注释写得很直白:
|
|
工作机制:
三个值得一提的设计细节:
- Writer 不持有任何 Session 引用。它只认识
notice_sink.notify()接口,Gateway 注册自己为实现者,通知按 session_id 路由,跨线程安全靠call_soon_threadsafe保证。Writer 因此可以在任何无 UI 场景独立运行。 - 可接受丢失语义:通知进的是 Session 的统一事件队列(和用户消息、渠道事件走同一条管道);session 已不存在时丢弃 + 打日志,不做持久化补偿。后台通知不是关键路径,宁可丢也不背复杂度。
- 不对称设计:同步操作(delete/edit)走 future 直接回传结果,不走 notify;只有异步 create 才需要。而且 delete/edit 的发起者就是用户本人,没什么好通知的——什么时候该喊一嗓子、什么时候闭嘴,边界划清楚比机制本身更重要。
这个模式的本质,是解决"后台组件如何以受控方式参与前台对话”:接口隔离(Protocol)+ 统一管道(事件队列)+ 分层决策(代码守门,LLM 判断说不说)。此后 Writer 不再只是默默写文件的工人,它能以标准事件的形式回到对话流里——要不要转述给用户、怎么说,交给语义判断。
2.4 Envelope:多端输入倒逼出的协议
7 月中,下一个 feature 是 Chrome 扩展:网页里划选一个词,侧边栏弹出聊天,小记结合选中文本、所在段落、页面 URL 回答。紧接着规划中的还有 PDF 插件、iOS 选词服务。
这个需求暴露了一个埋得太深的假设。当时的 Channel 抽象:
|
|
纯文本。四个致命问题:
- 结构化的上下文(选中段落、URL、设备信息)无处可放;
- 来源差异(web?扩展?微信?)被抹掉了;
- 没有 schema 版本机制,协议演进无从谈起;
- 最扎心的是歧义:如果约定"JSON 直接放在文本里",那用户在终端手敲
{"name": "mark"}会被当成结构化消息。
于是有了第二个早期 ADR:《Envelope》。所有渠道的用户输入统一包装成 UserInputEnvelope,序列化为 <envelope>{json}</envelope> 注入 prompt。schema 大致长这样:
|
|
四个我认为值得抄走的设计决策:
Agent 层零侵入。 Chat Agent 的接口签名完全没变,Session 层负责渲染成文本再传入。当时有过犹豫:让 Agent 直接接收结构化对象不是更类型安全?但那样 Agent 就和协议耦合了,协议每加字段,Agent 签名和一堆测试跟着动。事后看这个决策非常值:envelope 后来经历了两次 schema 重构(selection+context 合并为 tagged union 的 resource_contexts[];拆分独立的 chrome_ext 来源),涉及前后端十几个文件,Agent 层一行没改。
task 是偏好,不是命令。 envelope 里的 task=translate 只是用户偏好,LLM 可以自由决定是否遵循——比如用户选词翻译后又追问"为什么这里用 bank",Agent 应该先翻译再解释。意图分析的最终裁判始终是 LLM,协议层不越权。
带标签 JSON,不用 markdown。 LLM 看到的是有明确边界的 JSON 块,可以直接精确引用 source.url 这样的字段,不存在从 markdown 反向解析的有损问题。
未知 kind 直接报错,不静默 fallback。 source.kind 是 discriminated union,遇到不认识的值抛 ValidationError。静默 fallback 出的问题你三周后才会发现,而且是以最诡异的形式。
Envelope 后来证明是一次回报率极高的投资:图片附件来了,往 chat.attachments[] 一放(只放 SHA256 引用,字节不过协议);Vault 编辑器场景来了,往 resource_contexts[] 加一种 tagged union 就行。协议定稳一次,新应用界面从设计题变成填空题。
2.5 Vision Service:感知与行动的边界
8 月的新需求是图片:用户发来一张做题截图问"这题怎么做",拍一张单词书页面让小记讲解。微信和 Chrome 扩展也都要支持。
最容易的做法是把图片塞进 Chat Agent 的多模态消息里。但我们拆出了一个独立的 Vision Service,原因来自前面吃过的亏:感知和推理混在同一个上下文里,prompt 会膨胀失控,图片分析结果也无法缓存复用。
Vision Service 的设计只有两条边界,但每条都很硬:
感知边界:spec 第一条写道——Vision 只回答"图片里有什么",输出 OCR 文本 + 业务语义结构(合称 ImageAnalysis),绝不输出 answer / explanation。解题由 Chat Agent 基于 Vision 的返回完成。举个例子:用户发一张选择题截图,Vision 只输出题干和选项的文本结构;“答案选 B 因为 XXX"属于 Agent。这条禁令严格到什么程度?后来给 analyze_image 加了 instruction 参数(用户可以用文字指定理解请求),spec 里明确写了可用与不可用的边界:
✅
"圈住的单词是什么"、"用英文说说图中讲了什么故事"、"第一题的题干与选项"
❌ 解题/讲解类任务(如"选出正确答案")不得写入——由 Agent 基于返回结果完成
取用边界:Agent 不持原始图片字节,也不直接拿到分析结果。只持一个 src_resource_sha256 引用,取用的唯一路径是 analyze_image 工具,返回值作为标准 ToolMessage 进入消息历史,不搞 XML 注入之类的旁门左道。
为什么要这么麻烦?为了成本工程。图片分析贵且慢,Vision Service 内部做了三层防护:
- 上传成功后立即 fire-and-forget 触发分析(Eager Warm),利用用户"上传 → 按发送"的间隙预热缓存;
- 分析结果进 LRU+TTL 缓存,key 含图片 sha256、模型名、prompt 版本;
- 并发请求用 asyncio.Future 合并——同一张图无论几个调用方,视觉模型至多跑一次。
当然也踩了坑,两个印象深的:
坑一:event loop 绑定。 in_flight 里的 Future 绑定在创建它的 event loop 上。Eager Warm 和 analyze_image 如果跑在不同的 loop 里,就会爆 “awaiting future bound to a different loop”。修法是图片分析相关协程统一在同一个 event loop 执行。asyncio 的经典老坑。
坑二:缓存 key 少了一段。 设计时的 cache key 是 src + model + prompt_version,实现时发现 purpose 参数也影响 prompt 进而影响结果,不加进 key 会串缓存。这里留了一段我很喜欢的自我纠错记录——spec 里补了一条实现注记:
实现注记(与 ADR 字面的差异):实际实现额外追加了 purpose 段。原因:purpose 影响 prompt → 影响分析结果,若不纳入 key 会串缓存。
实现和文档有出入时,承认并记录偏差,好过让下一个读文档的人(或下一个会话的 agent)困惑半天。
这个模块最大的回报在扩展性上:8 月,Web 先上线图片聊天,几天后微信复刻(ImageStore/VisionService 都是进程级单例,同处 gateway 进程天然共享,后端零改动),随后 Chrome 扩展接入截图提问,还是零改动。当初"独立 Service"的决定,在第三个客户端接入的那天连本带利收回来了。
2.6 复盘:两条主线
回看这几次进化,其实一直在重复两个模式:
主线一:LLM 只做语义判断,机械性工作全部下沉代码。 Extract Agent 的退场、events 纯代码追加、系统字段 Literal 兜底、Vision 不许解题——全是它的变体。反过来也一样:什么时候该用 LLM?“是否告知用户"“怎么措辞"这种没有固定答案的语义问题,放心交给 LLM。
主线二:Agent 间的通信不断协议化。 从纯文本 Channel,到有 schema_version 的 Envelope,到线程安全的事件队列 + NoticeSink 反向通道,再到 trace id 全链路贯穿(下一章展开)。Agent 之间的"对话"逐步变成有契约、有线程保障、可观测的正式通信。多 Agent 协作的成熟度,看的不是 Agent 数量,是它们之间通信的质量。
三、Tracing:给多 Agent 系统装上行车记录仪
8 月下旬还工程债时,我做了一件之前一直拖着的事:给这套多 Agent 系统装 tracing。事后看这是回报最高的还债——它把前面讲的那些架构(异步流水线、跨线程协作、系统通知)全部变成了看得见的东西。
为什么推倒重做
项目此前用 langfuse 的 Python SDK 做 LLM tracing,问题有三个:
- trace 分散。tracing 初始化挂在每个 LLM 实例上,每次构建 LLM 都产生独立 trace——“一轮对话"在观测层面根本不存在,LLM 的思考、回复、工具调用散落在一条条孤立的记录里;
- 绑死单一后端。数据模型和导出通道都是 langfuse 特定的,想换后端或加一层 Collector,没门;
- 依赖声明也失真了:pyproject 写着
langfuse>=2.0.0,代码用的却是 4.x API。
于是立了一份 ADR:OpenTelemetry 作为唯一 tracing 协议层。巧的是 Langfuse v3+ 服务端原生接受标准 OTLP/HTTP,所以一套 OTel 栈可以同时对接 Langfuse 和任意 OTel 后端——协议中立反而保住了 langfuse 这个选项。
设计的三个关键点
一轮对话一条 trace。 Session 层每轮开一个 root span chat.turn,携带 session_id、channel、target_lang 等元数据;LangChain 运行时的 LLM 调用和工具调用由 instrumentation 库自动埋点,遵循 GenAI semantic conventions(model、token usage、tool_calls)。OTel context 存在 contextvars 里,同一个 asyncio task 内自动传播,业务代码一行不用改。
跨线程传播。 这是和 2.2 / 2.3 呼应的部分:Memory Writer 在 daemon 线程里跑,消费 entry 时 root span 通常已经结束。做法是 drafts 入队那一刻把当前 context 序列化成 W3C traceparent 写进 entry.trace_carrier,Writer 消费时 extract 还原再 attach——台前幕后两个 Agent 在 trace 树里重新汇合。两个易错点值得记下:attach 必须放在 asyncio.run() 之前(Runner 启动时会做 context 快照,晚了就丢了);parent 已结束是合法状态,后端按 parent_id 组树,晚到的 child 正常展示。
零故障降级。 原则很简单:tracing 是锦上添花,任何故障都不得影响聊天。未配置就是 no-op span(纯内存对象,开销可忽略);后端宕机时 BatchSpanProcessor 在后台线程默默重试、队列满则丢弃最旧;初始化异常整体降级为 no-op 加 warning 日志。请求路径上没有一次同步网络调用。
一个印象深的坑:LLM 费用不见了
trace 跑通后发现怪事:每条 LLM generation 都有完整的 token usage,但计费信息全是空的。
查下来是 Langfuse 的成本有两条来源路径:ingested cost(随 span 上报,优先)和 inferred cost(服务端按模型价格表推断)。inferred 路径要求 model 名匹配到价格定义,而 self-host 实例的价格表不认识 OpenRouter 风格的模型名(如 deepseek/deepseek-v4-flash-0731),推断失败;instrumentation 又只透传 token 计数,把响应里的真实美元成本丢掉了。更麻烦的是成本只在摄入时计算一次,历史 trace 不回填。
解法分两步:请求侧让 OpenRouter 开启 usage accounting(extra_body={"usage": {"include": True}}——注意这是 OpenRouter 私有参数,对官方 OpenAI 端点会报 400,所以做了条件注入);span 侧包装 instrumentation 内部的转换函数,把响应里的 cost 补写为 gen_ai.usage.cost 属性。
这里有个细节教训:属性必须落在 instrumentation 自己创建的那条 LLM generation span 上。一开始用业务侧 callback 写属性,触发时 current span 已经是外层的 runnable span——而 Langfuse 只对 generation 类型的 observation 计费,挂错位置等于没挂。
Self-host 测评环境
服务端选了 Langfuse v4 + OTel Collector 的 docker compose 方案。架构分工里我最满意的一点:认证收敛在 Collector——EverLingo 侧只配一个无密钥的内网 endpoint,Basic Auth 全部由 Collector 承担,业务配置里没有任何密钥:
|
|
重启 gateway,聊一句"翻译 hello”,几秒后在 Langfuse UI 里就能看到那条 chat.turn trace 展开:LLM 的中间思考和最终回复、每次工具调用的耗时、token 用量,以及稍晚到达的 memory.writer.process_entry span——2.3 节那个"后台写笔记"的过程,第一次变得肉眼可见。
用 opencode 分析 trace
最后是这个闭环里我最喜欢的部分:用 AI Agent 分析 AI Agent 应用的观测数据。
方法很朴素。langfuse 提供了 CLI 和官方 Agent Skill,装好之后 coding agent 就能直接查询生产环境的 trace:
|
|
然后直接对 opencode 说:“看看这条 trace 的计费信息是否存在,应用用的是 openrouter 的 deepseek/deepseek-v4-flash-0731。“它会自己拉 observations、检查 costDetails、给出结论。上面那个成本坑,就是这么排查出来的。
agent 写代码、agent 跑在生产上、agent 读自己的 trace 排查问题——到这里,闭环算是转起来了。
四、产品是怎么长出来的
技术之外,简单过一下功能线,因为它反映优先级判断。按月份粗粒度回看:
| 时间 | 里程碑 |
|---|---|
| 6 月中 | init,仓库里只有文档 |
| 6 月 | 微信渠道上线 → Web 聊天 → 记忆系统的起点(三段流水线) |
| 7 月上旬 | 向量检索(SQLite FTS5 + sqlite-vec) |
| 7 月中 | 首批 ADR(Envelope 协议、移除 Extract Agent);Chrome 扩展、笔记编辑器接连落地 |
| 7 月底 | 多用户架构(per-user Docker 容器编排)→ v0.1.0 正式版(8 月初) |
| 8 月 | i18n、图片聊天 + 笔记支持图片、Git 远端备份、扩展截图提问、Wiki 站点、OTel Tracing 基座 + 文档体系重构 |
三个观察:
渠道扩张验证了抽象的价值。 微信上线之后我每天都是真实用户——用外语、查词、被记住,产品需求几乎全部来自自己的真实挫败。而从第二个渠道起,每个新平台的接入工作量明显小于上一个——Channel 抽象和 Envelope 协议把"平台特有的东西"压缩到了最小集合,Chrome 扩展从设计到可用一周出头。
多用户改造是一次彻底的自我云原生。 方案是"一台宿主机 = 一个认证反代(WS-Router)+ 一个容器编排器(WS-Master)+ N 个 per-user 容器”,每个容器里跑的就是原来的单用户全家桶。这一大坨东西(数据库、CLI、Internal API、docker 生命周期、反向代理)是以几个连续的 PR 切片在很短时间里落地的——前提是之前花了整整一天写设计文档和分阶段计划。当设计足够细,写代码真的可以非常快。
i18n 不是翻译文案那么简单。 界面语言和学习目标语言是两个独立维度,连笔记库模板和事件格式都有语言归属。复杂度被低估过,后来补了好几份 spec 才理顺。
还有一个感受:工程化是欠的债,迟早要还。 前两个月几乎没管可观测性和文档一致性,8 月下旬集中还债:tracing 基座、文档体检、架构图全面文本化。好消息是有 ADR 和 TASKS.md 打底,还债时 Agent 能快速重建上下文;坏消息是该早点做的。
五、写给准备上路的人
74 天下来,如果只能留下几条建议:
-
先写规格再生成代码。文档是 agent 的 context,写文档就是编程的一部分。 仓库第一个 commit 只有文档不是巧合。我花在文档上的时间不少于看代码,回报是每次开新会话 agent 都带着完整决策背景开工。反过来,文档含糊的地方,agent 往往会用一个"合理但错误"的方式补齐。
-
文档要有强制维护回路。 光有文档没用,要让规则绑定行为:改码必更新 TASKS.md,实现偏离设计必询问,重大变更必写 ADR。你是架构师和 Reviewer,不再是打字员。
-
一切资产文本化。 图用 d2/mermaid 别用 GUI 工具,流程用 SKILL.md 别靠脑子记,决策用 ADR 别留在聊天记录里。本质相同:把协作界面从"人肉转述"改成"仓库即真相”。文本才能 diff、才能进 git、才能被你的 Agent 读懂和维护。
-
对 Agent 架构要做减法。 发现某个 Agent 剩下的价值配不上它的成本时,果断把它合并掉或下沉给代码。很多时候你要的只是一个新的工具调用,或 system prompt 里的一行规则。每多一个 Agent 就多一份通信、失败和观测成本。
-
LLM 只做语义判断,代码负责事实。 凡是需要 LLM 两次输出保持一致的地方都不可靠:去重在输入侧用代码做,事实字段由代码补全,枚举值用 Literal 锁定。让 LLM 干它擅长的事:理解模糊、权衡取舍、组织语言。
-
给偏差留档。 实现和设计文档有出入时,别偷偷改掉其中一方,在 spec 里写一条"实现注记”。三个月后的你自己,和每天重新出生的 agent,都会感谢这段文字。
最后打个广告:「记了么」已经开源可用,微信、Web/PWA、Chrome 扩展、终端都能接入。你负责学习,小记(一只仓鼠 🐹)负责记住。
回到开头那句话:这是第一次用 coding agent 做完整项目。74 天最大的体会不是"效率提升了几倍"这种空话,而是工作重心的转移——我从写代码的人变成了设计系统和维护 context 的人。代码是 agent 写的,但每一个"为什么这么设计"都得我来回答,而回答得越多,就越发现这些回答本身需要被妥善保管。
保管它们的地方,就是这个项目的文档体系。
参考
- EverLingo GitHub 仓库(本文所有 ADR、spec 均可在其中查证)
- ADR 20260719-remove_memory_extractor_agent — 移除 Extract Agent 的完整决策记录
- ADR 20260719-envelope — Envelope 协议设计
- Vision Service 图片理解设计文档
- Observability 设计文档 / ADR 20260824-otel-tracing-base
- Langfuse / OpenTelemetry
- d2 语言 / mermaid
- OpenCode