Goink 架构解析:用 Go 和 React 构建 AI 写作的结构化记忆系统
1. 从「金鱼记忆」到「结构化记忆」AI 写作工具到底缺了什么做过 AI 写作类产品的人大概都有过这种体验用户打开工具输入一个主题模型洋洋洒洒生成一大段文字看起来挺像回事。但用户接着想让它把第三章的主角年龄改成 28 岁或者把前面提到的那个设定再展开写 500 字模型立刻开始胡言乱语——要么忘了主角是谁要么把之前的情节线彻底推翻重来。这不是模型能力不行而是上下文管理这件事没做好。大语言模型本质上是无状态的每一次调用都是一次全新的推理它不会记得你上一轮说了什么除非你把历史信息重新喂给它。而当你把整段历史都塞进 prompt 时又会遇到 token 上限、成本飙升、关键信息被淹没等一系列问题。Goink 这个项目要解决的就是这件事。它的核心思路是用 Go 做后端把 AI 写作过程中产生的所有结构化信息——人物设定、情节线、世界观、章节大纲、写作风格偏好——存进一个可查询、可更新、可追溯的记忆系统里再通过 React 前端把这些记忆可视化地呈现给用户让用户能像编辑数据库一样编辑 AI 的记忆。换句话说它不是简单地做聊天记录保存而是把写作这件事拆解成实体Entity和关系Relation用类似知识图谱的方式管理。主角是一个实体主角的年龄是属性主角和配角的关系是边章节是容器情节是事件流。当用户要求修改某个设定时系统不是重新生成全文而是精准定位到受影响的实体和章节做局部更新。这套架构适合谁参考三类人一是正在做 AI 写作、AI 助手类产品的开发者尤其是被上下文管理折磨过的二是想学 Go 后端 React 前端全栈架构的中级工程师这个项目的分层设计很典型三是对AI 记忆系统这个概念感兴趣、想自己动手搭一个原型的技术爱好者。我接下来会从架构分层、记忆模型设计、前后端通信、状态同步、性能取舍这几个角度把这个项目的骨架拆开讲清楚。不是泛泛而谈用了什么技术栈而是讲清楚每个设计决策背后的权衡以及我在类似项目里踩过的坑。2. Goink 的分层架构为什么后端选 Go 而不是 Node2.1 三层结构的具体划分Goink 的架构可以粗略分成三层接入层、记忆层、生成层。接入层就是 React 前端 Go 的 HTTP/WebSocket 服务。前端负责交互和可视化后端负责路由、鉴权、请求编排。记忆层是核心包含实体存储、关系存储、版本快照三个模块底层用关系型数据库PostgreSQL 或 SQLite加一个轻量缓存Redis 或内存 LRU。生成层负责和模型 API 打交道包括 prompt 组装、流式响应解析、结果回写记忆层。这个划分的关键在于记忆层和生成层是解耦的。生成层可以替换任意模型供应商记忆层不关心文字是谁写的反过来记忆层的数据结构也不依赖具体模型的 prompt 格式。我在早期做类似功能时把 prompt 组装逻辑和业务数据混在一起结果换模型时改了三天这个教训很深刻。2.2 Go 在后端的真实优势很多人问AI 应用后端用 Python 不是更顺手吗生态全、库多。这话没错但 Goink 选 Go 有几个具体理由。第一是并发模型。AI 写作场景里一个用户可能同时开着多个章节的生成任务每个任务都是一个长连接流式响应。Go 的 goroutine 在这种大量轻量并发 IO 密集的场景下非常省心一个 goroutine 几 KB 栈空间几万个并发连接内存占用依然可控。用 Python 的 asyncio 也能做但一旦涉及 CPU 密集的文本处理比如 diff 计算、实体抽取的后处理GIL 就会成为瓶颈。第二是部署简单。Go 编译出来是单个静态二进制扔到服务器上就能跑不依赖运行时环境。对于个人开发者或小团队来说省掉容器编排和依赖管理的麻烦运维成本直线下降。第三是类型系统。记忆层的数据结构比较复杂——实体有类型、属性、版本、引用关系用 Go 的 struct 和 interface 定义出来编译期就能发现很多字段不匹配的问题。Python 的 dict 虽然灵活但在这种多层嵌套的数据结构里一个 key 拼错可能要跑到运行时才报错。当然 Go 也有代价文本处理的库不如 Python 丰富做复杂的 NLP 预处理会比较费劲。Goink 的做法是把重 NLP 的部分如实体识别交给模型 API 或独立的 Python 微服务Go 只做编排和存储。这个边界划得很清楚。2.3 React 前端的选型逻辑前端选 React 而不是 Vue 或 Svelte主要考虑的是状态管理的复杂度。AI 写作界面里同一份数据要在多个视图里同步左侧是章节树中间是编辑器右侧是实体面板底部是生成历史。用户在编辑器里改了一个人名实体面板要实时更新章节树里的引用计数也要变。这种一份数据、多处消费的场景React 的生态里用 Zustand 或 Jotai 这类轻量状态库配合 immer 做不可变更新心智负担比 Vue 的响应式系统更可控——尤其是当数据来自 WebSocket 推送、需要做乐观更新和回滚时。React 的并发特性useTransition、useDeferredValue在处理大列表渲染时也能派上用场比如实体列表有上千条时搜索过滤不会卡住输入框。3. 结构化记忆的数据模型实体、关系、版本三件套3.1 实体表的设计与字段取舍记忆层最核心的表是entities。一条实体记录大概长这样CREATE TABLE entities ( id BIGSERIAL PRIMARY KEY, project_id BIGINT NOT NULL, type VARCHAR(32) NOT NULL, -- character / location / item / concept name VARCHAR(128) NOT NULL, aliases JSONB DEFAULT [], -- 别名列表 attributes JSONB DEFAULT {}, -- 动态属性 version INT NOT NULL DEFAULT 1, created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now() );这里有几个设计决策值得说。为什么用 JSONB 存 attributes 而不是拆成独立的属性表因为写作场景里的属性是高度动态的。主角有年龄性格背景故事一把剑有材质攻击力来历一个地点有气候人口政治结构。如果每种类型都建一张表schema 会爆炸。JSONB 允许灵活扩展同时 PostgreSQL 支持对 JSONB 字段建 GIN 索引按属性查询的性能可以接受。为什么保留 aliases 字段这是实战里被逼出来的。AI 生成文本时经常用代称——他那位老人年轻的剑客都指主角。如果只按 name 匹配实体识别会漏掉大量引用。把别名单独存一份做引用解析时先查别名表命中率能提升一大截。version 字段的作用是配合快照表做版本回溯。每次实体被修改version 加一同时往entity_snapshots表里插一条旧版本记录。用户想回退到三章之前的设定时直接按 version 查快照即可。3.2 关系表的建模有向图还是无向图实体之间的关系用relations表存CREATE TABLE relations ( id BIGSERIAL PRIMARY KEY, project_id BIGINT NOT NULL, from_id BIGINT NOT NULL, to_id BIGINT NOT NULL, rel_type VARCHAR(32) NOT NULL, -- ally / enemy / parent / owns / located_in metadata JSONB DEFAULT {}, created_at TIMESTAMPTZ DEFAULT now() );关系是有向的。A 是 B 的父亲和B 是 A 的儿子是两条不同的边虽然语义相关但查询方向不同。如果强行做成无向图每次查询都要双向匹配索引效率会下降。有向图配合(from_id, rel_type)和(to_id, rel_type)两个复合索引查某人的所有盟友和谁把某人当盟友都很快。metadata字段用来存关系的附加信息比如盟友关系从第 5 章开始敌对关系在第 12 章解除。这样情节推进时关系的变化也能被追踪而不是简单地覆盖。3.3 版本快照与时间旅行entity_snapshots表结构很简单entity_id、version、snapshotJSONB存整个实体当时的完整状态、created_at。每次更新实体前先把当前状态写进快照表。这个设计的价值在于支持时间旅行式的查询。用户问第 8 章的时候主角是什么状态系统可以按章节对应的时间戳去快照表里找最近的版本。对于长篇小说创作这个功能非常实用——作者经常需要确认这个角色在某个时间点到底知不知道某件事。代价是存储膨胀。一个实体改 100 次就有 100 条快照。缓解办法是定期合并超过一定时间比如 30 天的快照只保留每个自然日的最后一个版本。这个策略在实操中够用因为作者回溯通常只关心某一天/某一章的粒度不需要精确到每次编辑。4. 前后端通信WebSocket 流式生成与状态同步的坑4.1 为什么生成任务必须走 WebSocketAI 生成文本是流式的一个字一个字往外吐。如果用普通 HTTP 请求要么等全部生成完再返回用户盯着空白屏幕等十几秒体验极差要么用 SSEServer-Sent Events。SSE 是单向的服务端推、客户端收对于纯生成场景够用。但 Goink 的场景里用户在生成过程中可能中途修改设定——比如等等把主角名字换成另一个——然后希望生成任务基于新设定继续。这就需要一个双向通道。WebSocket 允许客户端在生成过程中发送更新指令服务端收到后可以中断当前生成、重新组装 prompt、继续推流。SSE 做不到这一点只能断开重连。4.2 消息协议的设计Goink 的 WebSocket 消息用 JSON 格式每条消息有一个type字段区分用途{ type: generate.chunk, taskId: task_abc123, payload: { text: 他缓缓拔出, index: 42 } }常见的 type 有generate.start客户端发起生成、generate.chunk服务端推流、generate.done生成结束、generate.abort客户端中断、entity.update实体变更通知、sync.request客户端请求全量同步。关键设计点每个生成任务有独立的 taskId。这样即使用户同时开了三个章节的生成消息也不会串。服务端维护一个taskId - goroutine的映射中断时直接 cancel 对应的 context。4.3 状态同步的三种策略与选择前端状态和后端数据的一致性是个麻烦事。Goink 用了三种策略的组合策略一生成结果增量推送。生成过程中每个 chunk 直接 append 到前端编辑器不查数据库。生成结束后服务端把完整文本写库并推送一个generate.done消息前端收到后做一次校验。策略二实体变更广播。当某个实体被修改无论是用户手动改还是 AI 生成时自动抽取服务端向所有订阅了该项目的连接广播entity.update。前端收到后更新本地 store触发相关视图重渲染。策略三断线重连全量同步。WebSocket 断线是常态网络抖动、切后台。重连后客户端发送sync.request带上本地最后收到的时间戳服务端返回该时间戳之后的所有变更。这比全量拉取省流量也比纯增量可靠。注意增量同步一定要带单调递增的版本号或时间戳不能用最后一条消息 ID。因为消息可能乱序到达用 ID 做游标会漏数据。Goink 用的是数据库的updated_at配合一个全局序列号双保险。4.4 乐观更新的回滚处理用户在编辑器里改一个字前端立刻显示新内容乐观更新同时发请求给后端。如果后端返回失败比如版本冲突前端要回滚到旧内容。这个回滚逻辑如果写得不严谨会出现用户看到的内容和数据库不一致的诡异状态。Goink 的做法是每次乐观更新前把当前状态压入一个 undo 栈。后端确认成功就清栈失败就弹出恢复。同时给每个编辑操作分配一个本地递增的opId后端返回时带上对应的opId前端按 opId 匹配避免回滚了错误的操作。5. 记忆检索与 Prompt 组装怎么把结构化数据喂给模型5.1 检索的触发时机不是每次生成都要把整个记忆库塞进 prompt。Goink 的检索是按需触发的触发点有三个一是用户显式引用。用户在输入框里打主角系统解析出实体 ID把该实体的完整信息加入上下文。二是当前章节的关联实体。每个章节在数据库里关联了一组实体 ID写作时自动抽取或手动标注。生成该章节内容时只加载这些实体的信息。三是语义相似检索。用户输入一段描述系统用 embedding 在实体库里做相似度搜索召回 top-k 相关实体。这一步是可选的因为 embedding 调用有延迟和成本Goink 默认关闭用户可以在设置里开启。5.2 Prompt 模板的分层组装组装 prompt 时Goink 把内容分成几个优先级层次层级内容是否必带说明L1系统指令是定义写作风格、输出格式L2全局设定是世界观、核心规则L3当前章节大纲是本章要写什么L4关联实体详情是出场人物、地点的完整信息L5前文摘要条件上一章的压缩摘要控制长度L6语义召回可选相似实体按 token 预算裁剪组装时按 L1 到 L6 顺序拼接同时维护一个 token 计数器。当累计 token 接近模型上限的 80% 时从 L6 开始裁剪再不够就压缩 L5用更短的摘要L4 只保留核心属性。这个预算制组装逻辑是保证长文本生成不超限的关键。5.3 前文摘要的生成策略长篇小说写到几十章前文内容不可能全塞进 prompt。Goink 用滚动摘要每章生成结束后异步调用模型生成一个 200 字左右的章节摘要存进chapter_summaries表。生成新章节时加载最近 3 章的摘要 更早章节的卷摘要每 10 章合并一次。摘要的 prompt 要专门设计要求模型保留人物状态变化关键事件伏笔三类信息忽略环境描写和对话细节。实测下来这样生成的摘要用于续写时情节连贯性比直接截断前文要好得多。6. 性能与成本的平衡几个实测有效的优化手段6.1 实体抽取的异步化每次生成结束系统要从新文本里抽取实体和关系更新记忆库。这个抽取如果同步做用户会感觉生成完了还要等几秒才能继续。Goink 把它做成异步任务生成结束立即返回抽取任务扔进队列后台 worker 慢慢处理。前端通过 WebSocket 收到entity.update时再刷新面板。队列用 Go 的 channel worker pool 实现简单够用。如果任务量大可以换成 Redis 队列或更专业的消息中间件但对个人项目来说内存队列的复杂度最低。6.2 数据库索引的针对性优化记忆库的查询模式很固定按 project_id 过滤按 type 或 name 查找按关系遍历。对应的索引CREATE INDEX idx_entities_project_type ON entities(project_id, type); CREATE INDEX idx_entities_name_trgm ON entities USING gin(name gin_trgm_ops); CREATE INDEX idx_relations_from ON relations(from_id, rel_type); CREATE INDEX idx_relations_to ON relations(to_id, rel_type);gin_trgm_ops是 PostgreSQL 的模糊匹配索引支持LIKE %关键词%走索引。实体名搜索用这个比全表扫描快几个数量级。注意这个扩展要单独安装CREATE EXTENSION pg_trgm很多托管数据库默认不开。6.3 前端大列表的虚拟滚动实体面板可能有几百上千条记录。全量渲染 DOM 会卡。Goink 用react-window做虚拟滚动只渲染可视区域内的行。配合useDeferredValue处理搜索输入输入框的响应和列表过滤解耦打字不会卡。提示虚拟滚动和动态行高是一对矛盾。如果实体卡片高度不固定虚拟滚动的计算会很复杂。Goink 的做法是固定行高比如 64px超出部分用省略号点击展开详情。牺牲一点展示灵活性换取滚动流畅度这个取舍在列表场景里通常值得。6.4 模型调用的批处理与缓存同一章节里如果多个实体需要生成描述可以合并成一次模型调用让模型一次性输出多个实体的描述用 JSON 格式返回。这比逐个调用省 token 也省时间。缓存方面相同 prompt 的生成结果可以缓存。用户经常反复生成同一段内容做对比命中缓存直接返回成本为零。缓存 key 用 prompt 的哈希设置合理的过期时间比如 1 小时避免占用太多存储。7. 我在类似项目里踩过的几个坑第一个坑是过早优化数据模型。一开始我想设计一个完美的实体关系模型支持任意深度的嵌套和继承。结果 schema 复杂到我自己都记不住写查询要翻文档。后来推倒重来用最简单的实体 关系 JSONB 属性反而跑通了。写作场景的数据结构是演化的先跑起来再重构比一开始就追求完备更实际。第二个坑是忽略 WebSocket 的心跳和重连。上线后发现用户切到后台再回来连接已经断了但前端不知道还在往一个死连接发消息。加了心跳每 30 秒 ping 一次和自动重连指数退避之后才稳定。这个在本地开发时完全测不出来因为本地网络太稳了。第三个坑是prompt 里的实体信息太多导致模型跑偏。有一次我把一个配角的详细背景包括他的童年经历、家族历史全塞进 prompt结果模型在写主角打斗时突然开始回忆这个配角的往事完全偏离大纲。后来改成只加载与当前场景直接相关的属性无关的背景信息不喂。给模型的信息不是越多越好精准比全面重要。第四个坑是版本快照的存储爆炸。一个热门项目实体被改了上万次快照表涨到几个 G。后来加了定期合并策略并且把超过 90 天的快照归档到冷存储主库只保留最近版本查询性能才恢复。8. 如果要自己动手从哪一步开始如果你想复现这套架构我的建议是先做最小闭环一个 Go 服务一张 entities 表一个 React 页面能创建实体、能生成文本、生成时把实体信息拼进 prompt。跑通这个闭环你就理解了核心机制。然后再逐步加关系表、版本快照、WebSocket 流式、异步抽取、语义检索。每加一个功能都问自己这个功能解决了什么具体问题如果答不上来就先不加。技术栈上Go 用标准库的net/http加gorilla/websocket就够不需要上框架。数据库 PostgreSQL 或 SQLite 都行SQLite 适合单机部署PostgreSQL 适合多用户。前端 React Zustand react-window够用。模型 API 选一个支持流式输出的即可接口层做一层抽象方便替换。这套东西的价值不在于技术多新而在于把AI 写作这个模糊的需求拆解成了可管理、可查询、可回溯的数据结构。模型会变、prompt 会变但结构化的记忆是长期资产。我在实际使用中发现当记忆库积累到一定规模后它本身就成了创作的辅助工具——你可以查询所有在第 10 章到第 20 章之间出场过的角色或者所有和主角有过冲突的地点这些查询反过来能激发新的创作灵感。这大概是结构化记忆最意外的收获。