
如果你也折腾过 AI 写作类的应用大概会有个很别扭的体验对话时一切正常可写到第五章的时候模型已经忘了第一章埋下的伏笔。上下文窗口就那么大聊着聊着前面的信息就被挤出去了。我自己用 Go React 做了几个 AI 内容工具之后意识到问题不在模型的智商而在于我们压根没给它设计一套记忆机制。Goink 这个项目就是冲着这个问题去的——用 Go 写后端和记忆服务用 React 搭写作工作台核心是把 AI 的上下文从“临时窗口”升级成“结构化记忆”。这篇文章适合两类人一类是自己想做 AI 写作工具、卡在上下文管理上的开发者另一类是写长内容小说、专栏、系列课程时觉得 AI 越来越“健忘”的内容创作者。即便你的技术栈不是 Go 或 React这套“记忆服务”的设计思路也能直接搬到别的架构里。1. 需求拆解为什么 AI 写作需要“结构化记忆”1.1 对话式写作工具最难受的地方模型记不住自己说过的话用过 AI 写长文的人基本都有同感开头一段设定得清清楚楚写到最后一段它就“忘了”。你明确告诉它“主角叫陈默穿黑色风衣”二十轮对话之后它自己写成了“陈默穿了一件深灰色外套”。这不是模型变笨了而是上下文窗口在物理上装不下那么多信息。拿我自己的体感来说一个小时的连续对话token 消耗可能才几千但写作场景要求的不是“记住你刚刚说了什么”而是“记住整本书从头到尾发生了什么”。对话窗口像一块白板写满了就得擦。你让一个人边聊天边写十万字的小说他也接不住——他需要笔记本、索引卡、大纲文件。AI 写作工具缺的正是这个“外部笔记本”。这个痛点做产品的时候尤其明显。我见过不少写作类工具做完第一版对话功能后用户反馈都是“开头惊艳越写越崩”。不是模型不行是应用层没有给模型搭一套长期记忆的脚手架所有的“一致性”压力全压在模型自身的上下文窗口上自然撑不住。1.2 聊天记录不是记忆记忆库也不是聊天记录很多人说“那我把聊天记录存下来不就有了记忆吗”这是两码事。聊天记录是流水账是一条时间线记忆是经过提炼的事实、偏好、计划、风格约定。流水账适合回看不适合被模型直接利用。你把十万字的聊天记录全塞进 prompt先不说 token 够不够光是信息密度就低得吓人模型会在大量的“我们刚才聊了什么”里找不到真正该用的设定。结构化记忆更像写作时的“创作手册”它按类别组织每条记忆都是独立的、可检索的、可更新的。它记录的不只是“发生过什么”而是“这件事对后续写作意味着什么”。同样是记录“主角叫陈默”聊天记录是一条消息结构化记忆则是{ category: fact, content: 主角叫陈默男性穿黑色风衣, importance: 0.95, source: chapter-1-draft }这个小小的差别决定了模型在写第八章时能不能快速、低成本地重新想起陈默是谁。聊天记录是“翻聊天记录”结构化记忆是“查人物档案”效率完全不在一个量级。1.3 Goink 的设计出发点把记忆做成独立服务基于上面两个痛点Goink 的定位就清楚了它不是又一个 AI 聊天框而是一个“带记忆层”的写作工作台。产品形态上左边是编辑器右边是记忆面板架构上记忆不是前端临时拼出来的概念而是独立的存储与检索服务。我的设计出发点有三条记忆必须有结构不能是文本堆砌要能分类、打分、筛选、更新。记忆必须独立于对话流程写入异步进行用户写作不被阻塞。记忆必须可被用户审查和控制模型觉得重要不算数用户认可才算数。这三条几乎决定了整个后端和前端的技术选型。2. 技术选型为什么偏偏是 Go React2.1 后端选 Go不是情怀是“记忆服务”这个场景需要的特性记忆服务的核心操作是写入、检索、更新、删除外加一个异步抽取任务让模型从对话里提炼记忆。这些操作的特点是并发量高、单次逻辑不复杂、但需要稳定。Go 在这类“中等复杂度高并发服务”上的表现正好对路。Go 给我最直接的三个好处单二进制部署。Goink 的服务端我编译完就是一个可执行文件扔到服务器上就能跑不像某些技术栈还要装运行时、配一堆依赖。goroutine 处理异步抽取非常顺手。前端提交一段对话后接口可以先返回后台起 goroutine 去调模型接口做记忆抽取用户完全无感。这个“异步化”在写作场景里特别重要不能让用户等模型慢慢“思考”。标准库够用。HTTP 服务、JSON 解析、数据库操作都有成熟方案不需要为了一个记忆服务引入庞大的框架。用 Go 写一个记忆写入接口心智负担确实很小。比如下面这个简化版本func (s *Server) CreateMemory(w http.ResponseWriter, r *http.Request) { var mem Memory if err : json.NewDecoder(r.Body).Decode(mem); err ! nil { http.Error(w, invalid body, http.StatusBadRequest) return } mem.Importance clampImportance(mem.Importance) if err : s.store.Save(r.Context(), mem); err ! nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(mem) }不是说要写出多么惊世骇俗的代码而是这种“拿到请求、处理、返回”的直白节奏特别适合把精力放在记忆机制本身而不是耗在框架的弯弯绕绕上。2.2 前端选 React编辑器生态和组件化是真正的原因前端选 React 多少有点“共识”的味道但共识通常意味着正确。具体到 Goink 这个项目React 的价值有两点一是编辑器生态成熟。写作用户需要 Markdown 编辑、富文本看板、实时字数统计React 生态里能直接用现成的东西很多不用从零造轮子。二是组件化让“记忆面板”这种复杂交互变得可控。每个记忆是一张卡片卡片有确认、修改、删除、标星等操作天然适合拆成独立组件。React 的虚拟 DOM 在频繁更新记忆列表时也帮了大忙。写作时用户会持续看到新记忆卡片从右侧滑出如果每次都粗暴地整页重渲染编辑器光标位置早就丢了。React 的按需更新机制配合组件拆分能保证输入体验不被右侧面板拖垮。2.3 整体架构的模块划分与数据流向Goink 的代码结构比较收敛没有微服务就是把不同职责拆成清晰模块goink/ ├── server/ # Go 服务HTTP 接口、记忆读写、检索、AI 调度 ├── web/ # React 前端工作台、编辑器、记忆面板 ├── worker/ # 异步任务记忆抽取、去重、摘要生成 └── migrations/ # 数据库迁移脚本数据流向是这样的前端把用户的写作内容和对话提交给 serverserver 一方面把对话回调给模型接口获取回复另一方面把这段对话丢给 workerworker 异步调用模型做“记忆抽取”然后把提炼出的结构化记忆写入数据库后续再生成内容时server 从数据库检索相关记忆拼进系统 prompt。一次完整的写作过程被拆成了“写”、“记”、“用”三段。“写”是编辑器“记”是记忆抽取“用”是检索注入。Goink 的核心竞争力全在“记”和“用”这两个环节这也是我把大部分设计和调优精力放在记忆服务上的原因。3. 结构化记忆的核心设计模型、写入链路与检索注入3.1 记忆模型五种分类与数据库设计记忆分类是整个设计的地基。分类不合理的记忆库跟聊天记录没什么区别一样是一团浆糊。Goink 把记忆分成五类分类含义例子fact不可改变的事实主角叫陈默设定发生在冬季style表达风格与偏好使用短句避免华丽形容词plan大纲和结构约定第三章之前不揭示反派身份progress当前写作进度第二章已完成正在起草第三章reference外部素材与参考资料某篇文章的关键数据某本书的章节梗概数据库设计上Goink 用 PostgreSQL一张 memories 表搞定核心字段如下CREATE TABLE IF NOT EXISTS memories ( id BIGSERIAL PRIMARY KEY, workspace_id BIGINT NOT NULL, category VARCHAR(32) NOT NULL, payload JSONB NOT NULL, summary TEXT NOT NULL DEFAULT , source VARCHAR(255) NOT NULL DEFAULT , importance REAL NOT NULL DEFAULT 0.5, content_hash VARCHAR(64) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (workspace_id, content_hash) ); CREATE INDEX idx_memories_workspace_category ON memories (workspace_id, category); CREATE INDEX idx_memories_retrieval ON memories (workspace_id, importance DESC, updated_at DESC);payload 用 JSONB 是为了保持灵活性衣服颜色、人物关系、章节状态这些内容格式不同没必要为每种内容建一张表。summary 是给模型拼 prompt 用的短摘要importance 是权重content_hash 是防重复写库的抓手。3.2 写入链路从一段对话到一条结构化记忆记忆不是用户写完自动产生的而是“抽取”出来的。Goink 的 worker 里跑着一个固定的抽取流程拿一段最近的对话记录用户输入 模型回复 编辑器的上下文摘要。调用模型接口让模型按规则抽取候选记忆。对候选记忆做清洗、去重、重要性打分。写入数据库。抽取用的 prompt 长这样我贴出来直接可以抄你是一个记忆提取器。请从下面的对话中提取出对后续写作有用的信息。 要求 - 只提取事实、风格、计划、进度、参考资料五类信息。 - 每条输出 JSON格式为 {category: fact|style|plan|progress|reference, content: 简要描述, importance: 0.0-1.0} - 同一信息出现多次时importance 取更高值。 - 无关紧要的寒暄、与写作无关的内容一律忽略。 对话内容 {conversation}对应到 Go 代码里记忆结构体的定义很简单type Memory struct { ID uint64 json:id WorkspaceID uint64 json:workspace_id Category string json:category Content string json:content Summary string json:summary Importance float64 json:importance Source string json:source CreatedAt time.Time json:created_at UpdatedAt time.Time json:updated_at }每次抽取完worker 会先算 content_hash然后执行 Upsert。这样同一个信息即使多次出现也不会在记忆库里造出十几条副本。3.3 检索注入不是所有记忆都要塞给模型记忆库里的东西越多检索的挑战越大。如果每次写作都把所有记忆塞给模型token 爆掉只是时间问题。Goink 的检索策略分两步召回和重排。召回阶段只考虑三类记忆当前章节相关的事实、全局的风格约定、未完成的计划。重排阶段按一个简单公式打分score 0.5 * qa_similarity 0.3 * importance 0.2 * recency初始版本没引入向量数据库qa_similarity 用的是关键词重叠 分类匹配。够用而且不增加额外依赖。真正的取舍在于写作时全局风格约定的优先级最高事实其次参考资料最后。注入 prompt 时Goink 会把检索到的记忆拼成一个“背景记忆块”func BuildMemoryBlock(memories []Memory) string { if len(memories) 0 { return } var sb strings.Builder sb.WriteString(以下是本项目的历史背景记忆写作时必须保持一致\n) for _, m : range memories { sb.WriteString(- [ m.Category ] m.Summary \n) } return sb.String() }这个记忆块会被放在系统 prompt 的固定位置模型每次生成时都能看到。实测下来长文的一致性提升非常明显至少不会在第五章把主角的名字写错了。4. 写作工作台React 前端如何把“记忆”变成可用的界面4.1 编辑器与状态管理核心是“不互相拖累”Goink 的编辑器没做成“记忆面板的附属品”而是跟记忆状态彻底解耦。编辑器自己管草稿内容记忆面板单独管记忆列表两者通过一个很薄的事件总线通信。这样无论右侧面板怎么刷新编辑器光标都不会抖。前端状态管理用了轻量方案核心是一个记忆 storeimport { create } from zustand interface MemoryStore { memories: Memory[] currentId: string | null setMemories: (memories: Memory[]) void updateMemory: (id: string, patch: PartialMemory) void deleteMemory: (id: string) void } export const useMemoryStore createMemoryStore((set) ({ memories: [], currentId: null, setMemories: (memories) set({ memories }), updateMemory: (id, patch) set((state) ({ memories: state.memories.map((m) m.id id ? { ...m, ...patch } : m ), })), deleteMemory: (id) set((state) ({ memories: state.memories.filter((m) m.id ! id), })), }))zustand 这类方案的好处是心智负担低不需要 Redux 那一套 action/reducer 的概念一个小写作工具背不动那么重的状态层。4.2 记忆面板的交互设计反馈闭环比自动提炼更重要记忆面板不是一堆卡片的静态陈列它承担着“用户纠正模型”的重任。每张记忆卡片上有三个操作采纳、修改、删除。用户点了“修改”会弹出一个编辑框让用户改写内容点了“删除”这条记忆就不会再被检索注入。这个反馈闭环非常关键。模型自动抽取的记忆不可能 100% 正确如果用户没有途径纠错错误会被一遍遍注入 prompt然后模型越写越歪。Goink 把“用户修改记忆”的优先级提到最高任何用户手动编辑过的记忆在后续打分时 importance 直接 0.2确保它更可能被检索到。这个机制在实战里拯救了无数次跑偏的剧情线。4.3 实时同步与长文场景的性能处理写作过程中后台会不断产生新记忆异步抽取的结果前端需要实时感知。Goink 用 WebSocket 推送记忆更新事件前端收到消息后把新记忆插入列表所有操作都在前端内存里完成不触发整页刷新。长文场景下还有两个性能细节记忆列表用虚拟滚动。几百条记忆卡片如果一次性全部渲染成 DOM滚动能卡出 ppt 效果。虚拟滚动只渲染可视区域的十几条体感完全不一样。检索请求做防抖。用户停止写作 2 秒后才触发新的相关记忆检索不每敲一个字就请求一次。这两个优化做完写作体验才真正称得上“流畅”。5. 实操记录与踩坑复盘5.1 从零启动一个 Goink 开发环境的完整步骤如果你想把这套架构跑起来环境准备大概需要三步。第一步起基础设施。在 docker-compose 里定义 PostgreSQL 和 Redisservices: postgres: image: postgres:16 environment: POSTGRES_USER: goink POSTGRES_PASSWORD: goink POSTGRES_DB: goink ports: - 5432:5432 redis: image: redis:7 ports: - 6379:6379第二步配置环境变量。在 server 目录下建一个 .env至少包含DATABASE_URLpostgres://goink:goinklocalhost:5432/goink?sslmodedisable REDIS_URLlocalhost:6379 LLM_API_KEY你的大模型接口密钥 LLM_BASE_URL你的大模型接口地址第三步启动服务。后端跑起来之后前端直接连本地接口cd server go run ./cmd/server cd ../web pnpm install pnpm dev浏览器打开工作台新建一个项目随便写一段设定再发一句指令给模型助手右侧记忆面板会在几秒后出现自动提炼出的记忆卡片。5.2 三个典型问题的排查实录开发过程中最常踩的坑有三个逐个说。第一个坑记忆重复写入。刚开始没有 content_hash 唯一约束模型每次抽取都会把“主角穿黑风衣”又提炼一遍没几天记忆库里全是重复条目。检索的时候十条里有八条在说同一件事prompt 的有效信息密度急剧下降。解法就是前面说的 Upsert插入时已存在相同 hash就更新旧条目的 importance 和 updated_at不新插。第二个坑抽取任务阻塞了主流程。第一版里“提交对话 - 抽取记忆”是同步的模型抽取一次要花两三秒前端一直转圈用户急得直按回车。后来把抽取挪到了 worker 里异步执行接口先返回答复内容记忆面板里的卡片是“缓缓出现”的。用户不感知抽取过程体验立刻顺了。第三个坑前端面板卡成 PPT。排查了一圈发现是每张记忆卡片都在订阅全局状态任何一条记忆更新所有卡片全部重渲染。改成 React.memo 包裹卡片组件、列表用虚拟滚动之后几百条记忆也丝般顺滑。5.3 记忆质量的几个隐蔽瓶颈比踩坑更隐蔽的是记忆质量上不去的几个原因。第一重要性打分偏主观。模型会给每条记忆打 0 到 1 的 importance但模型觉得重要的东西用户未必觉得重要。后来我做了一个统计用户主动修改过的记忆平均价值远高于模型高分记忆。所以现在 Goink 的权重里用户反馈修正值的优先级是最高的。第二分类错误会造成“找不到记忆”。一条明明是 style 的记忆被归类成 fact检索时需要按 category 过滤时它就漏掉了。这个暂时没有完美方案只能靠用户手动修改分类以及在抽取 prompt 里反复强调分类标准。第三记忆库需要定期“瘦身”。很多记忆刚写入时有用过了一周就成了噪音。Goink 的解决方式很朴素超过三十天没有被检索、没有被用户打开过的低重要性记忆后台任务自动归档。记忆不是越多越好高质量、可检索的记忆才有价值。写在最后的一点体会做 Goink 这段时间我最大的感触是结构化记忆的本质不是“记住更多”而是“忘得更聪明”。真正可靠的记忆层必须克制——不是每条信息都值得进长期记忆不是每条记忆都该被塞进 prompt。只有那些反复出现在不同章节里的设定、用户主动纠正过的表达风格、真正影响故事走向的关键事件才值得固化成记忆条目。后续我打算给记忆加一层关联关系让记忆之间能互相引用比如“主角的职业设定”关联到“他为什么会在第三章做出那个选择”。但先把读和写做扎实比什么都重要。毕竟记忆系统一旦不靠谱AI 写作工具就从一个好帮手变成了一个固执的造谣者。