1. 从「AI 写作失忆」说起这个项目到底在解决什么用 AI 写过长文的人大概都有过这种体验第一轮聊得挺好人物设定、世界观、章节大纲都对齐了结果聊到第五章它突然把主角的名字写错或者把之前埋的伏笔忘得一干二净。你不得不把前面几万字重新贴一遍token 烧得心疼效果还不稳定。这个痛点本质上不是模型能力不够而是上下文管理没做好——AI 没有一套可靠的「结构化记忆」。Goink 这个项目就是冲着这个问题去的。它用 Go 做后端、React 做前端给 AI 写作搭了一套带结构化记忆的工作台。核心思路可以概括成一句话把「记忆」从对话流里抽出来变成可查询、可更新、可追溯的结构化数据让模型每次生成时只拿它真正需要的那部分上下文而不是把整本书硬塞进去。这套东西适合谁如果你只是偶尔让 AI 写个朋友圈文案那确实用不上。但如果你在做长篇小说辅助创作、系列内容策划、剧本分集生成、知识库驱动的专业写作这类需要跨多轮、跨章节保持一致的场景Goink 的思路就很值得参考。它解决的不是「让 AI 写得更漂亮」而是「让 AI 写得前后一致、记得住事」。我先把它的整体架构拆开讲清楚再落到具体实现细节最后把我踩过的坑和排查经验一并倒出来。全文会围绕 Go 后端、React 前端、结构化记忆这三条主线展开尽量做到你看完能自己复现一个简化版。2. 整体架构设计为什么是 Go React 结构化记忆2.1 三层分离的设计哲学Goink 的架构不是拍脑袋定的它遵循一个很朴素的原则计算归计算展示归展示记忆归记忆。这三件事的变更频率完全不同——记忆结构可能一周调一次业务逻辑几天改一次UI 可能一天改好几次。如果全揉在一起改一处就牵一发动全身。所以它拆成了三层记忆层Memory Layer负责存储和管理结构化记忆包括角色卡、世界观设定、情节时间线、伏笔追踪表等。这一层是纯数据不关心谁在用它。编排层Orchestration LayerGo 后端所在的位置。它负责根据当前写作任务从记忆层捞出相关片段组装成 prompt调用模型再把模型返回的内容解析回结构化数据写回记忆层。交互层Interaction LayerReact 前端。它把记忆可视化——角色关系图、时间线、伏笔状态让作者能直观地看到「AI 现在记得什么」并且能手动修正。这么拆的好处是记忆层可以独立测试编排层可以换模型而不动前端前端可以换框架而不动记忆结构。我实测下来这种分离在迭代期省了大量返工。2.2 为什么后端选 Go 而不是 Node 或 Python这是很多人第一个会问的问题。AI 应用生态里 Python 是绝对主流Node 也有一堆现成库为什么偏偏用 Go我的理解有三点。第一并发处理。写作工作台经常要同时做多件事一边流式返回生成内容一边后台更新记忆索引一边响应前端的记忆查询请求。Go 的 goroutine 和 channel 处理这种并发场景非常顺手代码写起来也干净。第二部署简单。Go 编译出来是单个二进制文件扔到服务器上就能跑不用折腾虚拟环境和依赖冲突这对个人项目和小团队特别友好。第三性能稳定。记忆检索涉及大量字符串匹配和结构化查询Go 在这块的性能表现比动态语言更可预测不会因为 GC 抖动导致流式输出卡顿。当然如果你团队全是 Python 背景硬上 Go 也没必要。架构思路是通用的语言只是实现手段。2.3 结构化记忆到底「结构化」在哪这是整个项目的灵魂得说透。所谓结构化记忆就是把原本散落在对话历史里的信息抽成有明确字段和关系的数据结构。我拿角色记忆举个例子记忆类型非结构化形态结构化形态角色基本信息「主角叫林澈28岁是个侦探」{name: 林澈, age: 28, role: 侦探}角色关系「林澈和苏晚是青梅竹马」{from: 林澈, to: 苏晚, type: 青梅竹马}角色状态「林澈在第三章受了腿伤」{character: 林澈, state: 腿伤, since: ch3, status: active}伏笔「第一章提到的那把钥匙还没用」{item: 钥匙, planted: ch1, resolved: null}结构化的价值在于可查询、可校验、可增量更新。当 AI 要写第十章时后端可以精确查询「所有 status 为 active 的角色状态」和「所有 resolved 为 null 的伏笔」只把这些塞进 prompt而不是把前十章全文贴进去。token 消耗能降一个数量级一致性反而更好。3. 记忆层的核心设计数据结构与存储选型3.1 记忆实体的字段设计记忆层的数据模型是整个系统的地基设计得好后面省心设计得差后面全是补丁。Goink 把记忆实体分成几大类每类都有明确的字段约束。角色卡Character是最核心的实体字段包括唯一 ID、姓名、别名列表、年龄、身份、外貌描述、性格标签、当前状态列表、关系列表。这里有个细节值得说——别名列表。很多 AI 写作翻车是因为同一个角色在不同章节被叫了不同称呼模型就以为是两个人。把别名显式存下来检索时一并匹配能大幅降低这类错误。世界观设定WorldSetting字段包括设定名称、类别地理、势力、规则、历史、描述、关联实体、生效范围。伏笔追踪Foreshadow字段包括伏笔内容、埋设章节、预期回收章节、当前状态、关联角色。时间线Timeline比较特殊它是一条有序事件链每个事件有发生时间、涉及角色、地点、因果前置。这条链是保证叙事逻辑不崩的关键。3.2 存储选型为什么用 SQLite 起步存储这块Goink 的选择很务实起步用 SQLite规模上来再考虑 PostgreSQL。理由很直接——个人写作项目的数据量其实不大一本百万字小说结构化记忆撑死几万条记录SQLite 完全扛得住而且零配置、单文件、方便备份。我见过有人一上来就上分布式数据库结果光运维就耗掉一半精力。写作工具的核心价值在记忆逻辑不在存储引擎。等你的用户量真的上来了再迁移也不迟因为记忆层是独立的换存储不影响上层。具体表结构上我用一张主表存实体一张关系表存实体间关系一张事件表存时间线。关系表用from_id、to_id、relation_type三个字段表达这样查「某角色的所有关系」就是一次简单查询。CREATE TABLE entities ( id TEXT PRIMARY KEY, type TEXT NOT NULL, name TEXT NOT NULL, aliases TEXT, data JSON NOT NULL, created_at INTEGER, updated_at INTEGER ); CREATE TABLE relations ( id TEXT PRIMARY KEY, from_id TEXT NOT NULL, to_id TEXT NOT NULL, relation_type TEXT NOT NULL, data JSON, FOREIGN KEY (from_id) REFERENCES entities(id), FOREIGN KEY (to_id) REFERENCES entities(id) );data字段用 JSON 存类型特有的属性这样新增字段不用改表结构灵活性很高。这是我在多个项目里验证过的实用做法。3.3 记忆的写入时机与去重策略记忆什么时候写这是最容易出问题的地方。如果每轮对话都无脑写记忆会迅速膨胀且充满重复如果攒着批量写又可能丢失关键信息。Goink 的策略是事件驱动写入模型每次生成完内容后后端会跑一个「记忆抽取」步骤从生成文本里识别出新的角色信息、状态变化、伏笔埋设然后和已有记忆做比对。比对逻辑分三种情况新增记忆库里没有这个实体直接插入。更新实体存在但字段有变化比如角色状态从「健康」变成「腿伤」更新对应字段并保留历史。冲突新信息和旧信息矛盾比如年龄对不上这时候不自动覆盖而是标记为待确认推给前端让作者裁决。注意冲突检测千万别做成自动覆盖。我早期图省事让新数据直接覆盖旧的结果模型偶尔抽风写错一个数字就把正确设定冲掉了追都追不回来。标记待确认虽然多一步交互但安全得多。去重这块我用的是「名称 别名 类型」的组合键做模糊匹配再叠加一层编辑距离判断。名称完全一致直接判定同一实体编辑距离小于阈值的提示可能是同一实体让作者确认。4. 编排层的实现Go 后端如何组装上下文4.1 上下文组装的完整流程这是整个系统最核心的一环也是最能体现「结构化记忆」价值的地方。当作者要生成第 N 章时后端不是把前 N-1 章全塞进去而是走一套精确的组装流程。第一步确定生成任务类型。是续写、改写、还是生成大纲不同任务需要的上下文不同。续写需要当前场景相关的角色和伏笔生成大纲需要整体世界观和主线走向。第二步检索相关记忆。根据任务类型从记忆层查询对应实体。比如续写当前场景就查「当前场景涉及的角色」「这些角色的 active 状态」「这些角色相关的未回收伏笔」「当前时间线位置前后的事件」。第三步按优先级排序并裁剪。记忆条目按重要性打分——直接涉及当前场景的角色权重最高间接相关的次之背景设定最低。然后按 token 预算从高到低填充超预算的截断。第四步组装成结构化 prompt。不是简单拼接而是用清晰的分区标注比如「【角色设定】」「【当前状态】」「【待回收伏笔】」让模型能明确区分信息类型。func BuildContext(task Task, budget int) (string, error) { memories : retrieveRelevantMemories(task) scored : scoreAndSort(memories, task) selected : truncateByBudget(scored, budget) return formatPrompt(selected), nil }这套流程跑下来一个原本需要几万 token 的上下文能压缩到几千 token而且关键信息一个不漏。4.2 流式输出与记忆更新的并行处理写作体验里流式输出是刚需——作者要看到文字一个字一个字蹦出来而不是等半天突然出现一大段。但记忆更新又需要在生成完成后才能做因为要解析完整文本。Goink 的做法是用 goroutine 把这两件事解耦。主 goroutine 负责流式返回给前端同时把完整内容累积到一个 buffer另一个 goroutine 监听 buffer等生成结束后触发记忆抽取和写入。两者通过 channel 通信互不阻塞。func (s *Service) Generate(ctx context.Context, req Request) (-chan Chunk, error) { chunks : make(chan Chunk) go func() { defer close(chunks) var full strings.Builder stream : s.model.Stream(ctx, req.Prompt) for piece : range stream { full.WriteString(piece) chunks - Chunk{Text: piece} } // 生成结束异步更新记忆 go s.memory.ExtractAndUpdate(full.String(), req.Context) }() return chunks, nil }这里有个坑记忆更新是异步的如果作者在更新完成前就发起下一轮生成可能读到旧记忆。我的处理是给记忆加一个版本号前端在生成前先拉一次最新版本版本不一致就等更新完成。这个细节不做会出现「刚写的设定下一轮就不认」的诡异现象。4.3 模型调用的抽象与切换Goink 没有把模型调用写死而是抽象成一个接口。这样你可以今天用这个模型明天换那个后端代码不用动。type Model interface { Stream(ctx context.Context, prompt string) (-chan string, error) Complete(ctx context.Context, prompt string) (string, error) }不同模型的 prompt 格式、参数、返回结构都不一样这些差异都在各自的实现里消化掉。编排层只认这个接口。我实测下来这种抽象在模型快速迭代的当下特别值——新模型出来写个适配器就能接上不用改业务逻辑。参数选择上写作场景我一般把 temperature 设在 0.7 到 0.9 之间太低会写得干巴巴太高容易跑偏。续写任务可以稍低创意发散任务可以稍高。这个没有标准答案得根据你的内容类型调。5. React 前端的记忆可视化让作者看得见 AI 在想什么5.1 记忆面板的交互设计前端这块Goink 最出彩的不是编辑器而是记忆面板。传统 AI 写作工具作者只能看到对话看不到 AI「脑子里」存了什么。Goink 把记忆可视化出来作者能实时看到角色卡、关系图、伏笔状态还能手动编辑。记忆面板分几个视图。角色视图用卡片列表展示所有角色每张卡显示核心字段和当前状态点击展开详情。关系视图用图结构展示角色间关系节点是角色边是关系类型。时间线视图用横向时间轴展示事件链伏笔用特殊标记标出「已埋未收」的状态。这种可视化的价值在于可干预。当作者发现 AI 把某个角色写崩了不用在对话里反复纠正直接去记忆面板改角色卡下一轮生成就会用新设定。这比跟模型「讲道理」高效得多。5.2 状态管理与实时同步React 这边用状态管理库统一管理记忆数据所有视图共享同一份数据源改一处全联动。实时同步靠 WebSocket后端记忆一更新前端立刻收到推送刷新。这里有个性能细节记忆数据可能很大全量推送会卡。我的做法是增量推送——后端只推变化的部分前端做局部更新。配合 React 的虚拟列表几千条记忆也能流畅渲染。const useMemorySync (projectId) { const [memories, setMemories] useState({}); useEffect(() { const ws new WebSocket(/ws/memory/${projectId}); ws.onmessage (e) { const delta JSON.parse(e.data); setMemories(prev applyDelta(prev, delta)); }; return () ws.close(); }, [projectId]); return memories; };5.3 编辑器与记忆的双向联动编辑器和记忆面板不是割裂的而是双向联动。作者在编辑器里选中一段文字如果这段文字提到了某个角色记忆面板会自动高亮对应角色卡。反过来作者在记忆面板点击某个伏笔编辑器会跳转到埋设它的位置。这种联动靠的是文本实体识别。后端在生成或保存时会跑一遍实体链接把文本里的角色名、地名、物品名映射到记忆实体 ID。前端拿着这些映射做高亮和跳转。识别准确率不可能 100%但配合别名列表实测能覆盖绝大多数情况。6. 实操复现从零搭一个简化版6.1 环境准备与项目骨架想自己复现一个简化版先把环境搭起来。Go 装 1.21 以上Node 装 18 以上SQLite 不用单独装Go 的驱动会处理。项目结构建议这样分goink/ backend/ main.go memory/ # 记忆层 orchestrator/ # 编排层 api/ # HTTP 接口 frontend/ src/ components/ hooks/ store/后端用标准库的 net/http 就够不用上框架。前端用 Vite 起 React 项目状态管理选轻量的方案别一上来就上重型库。6.2 记忆层的落地实现先实现记忆的增删改查。核心是一个 MemoryStore 结构封装 SQLite 操作。type MemoryStore struct { db *sql.DB } func (m *MemoryStore) UpsertEntity(e Entity) error { // 先查是否存在 existing, err : m.GetEntityByName(e.Name, e.Type) if err nil existing ! nil { // 存在则更新保留历史 return m.updateEntity(existing.ID, e) } // 不存在则插入 return m.insertEntity(e) }更新时保留历史很重要我用一张 history 表记录每次变更这样作者能回溯「这个设定是什么时候改的、改成什么样」。这个功能在长篇创作里救过我好几次。6.3 编排层的上下文组装编排层核心是 BuildContext 函数前面给过骨架这里补充检索逻辑。func retrieveRelevantMemories(task Task) []Memory { var result []Memory // 1. 当前场景涉及的角色 for _, charID : range task.SceneCharacters { char : store.GetEntity(charID) result append(result, char) // 2. 这些角色的 active 状态 result append(result, store.GetActiveStates(charID)...) // 3. 这些角色相关的未回收伏笔 result append(result, store.GetOpenForeshadows(charID)...) } // 4. 当前时间线位置前后的事件 result append(result, store.GetNearbyEvents(task.TimelinePos, 3)...) return result }检索完打分排序直接涉及当前场景的权重 1.0间接相关的 0.5背景设定 0.2。按 token 预算从高到低填填满为止。6.4 前端记忆面板的最小实现前端先做角色卡列表和详情编辑这是最实用的部分。function CharacterPanel({ projectId }) { const memories useMemorySync(projectId); const characters Object.values(memories).filter(m m.type character); return ( div classNamepanel {characters.map(c ( CharacterCard key{c.id} character{c} / ))} /div ); }CharacterCard 里做字段编辑改完调后端接口更新。关系图和时间线可以后面再加先把角色卡跑通就能感受到结构化记忆的威力了。7. 常见问题与排查技巧实录7.1 记忆抽取不准怎么办这是最高频的问题。模型生成的内容里角色信息可能藏得很深抽取逻辑容易漏。我的经验是别指望一次抽全用多轮抽取 人工确认兜底。第一轮用规则匹配识别显式的「XX 是 XX」句式。第二轮用模型做一次专门的抽取调用把生成文本喂进去让它输出结构化 JSON。两轮结果合并去重。剩下的漏网之鱼靠前端在作者编辑时手动补充。抽取的 prompt 要写清楚输出格式最好给几个示例。我试过不给示例模型输出格式五花八门解析起来头大。7.2 上下文超预算怎么优雅截断token 预算超了不能简单砍尾巴那样可能把关键信息砍掉。我的策略是按优先级分层截断先砍背景设定再砍间接相关记忆最后才动直接相关记忆。直接相关记忆如果还超就把长描述截断成摘要保留核心字段。摘要可以用模型生成也可以预置规则。角色外貌描述这种截断到前 50 字通常够用。伏笔描述必须完整保留因为少一个字可能就理解错了。7.3 记忆冲突的排查思路记忆冲突表现为「AI 前后不一致」。排查时先看记忆库确认是不是存了矛盾数据。如果是看冲突是怎么产生的——是抽取错误还是模型生成时就写错了。排查工具我做了个「记忆变更日志」页面按时间倒序列出所有记忆变更每条显示变更前后对比。这样一眼就能看出是哪次生成引入了错误数据。这个工具帮我定位过好几次诡异 bug。7.4 常见问题速查表问题现象可能原因排查方向解决手段AI 忘记角色设定记忆未检索到查检索日志检查角色是否入库、别名是否匹配角色名前后不一致别名未关联查别名列表补充别名重新链接伏笔被遗忘状态未标记 active查伏笔状态修正状态加入检索范围生成内容跑偏上下文组装有误查组装后的 prompt调整检索权重和预算记忆更新延迟异步未完成查版本号前端等待版本同步token 消耗过高检索范围过大查检索结果数量收紧检索条件提高阈值提示这张表建议打印出来贴在显示器边上出问题时按行排查比瞎试快得多。8. 我在实际项目里踩过的坑说几个文档里不会写、但实际做起来一定会遇到的坑。第一个是记忆膨胀。项目跑久了记忆库会积累大量废弃数据——改过的旧设定、删掉的角色、回收完的伏笔。这些不清理检索时会拖慢速度还干扰结果。我的做法是给记忆加软删除标记定期归档检索时默认过滤已归档的。第二个是模型对结构化 prompt 的适应。不是所有模型都擅长读结构化输入。有的模型看到「【角色设定】」这种标记会当成正文写进去。解决办法是在系统提示里明确说明这是元信息或者换成更自然的表述方式。这个得针对具体模型调。第三个是前端状态同步的竞态。作者手速快的时候可能在前一个记忆更新还没同步完就发起下一个操作导致数据错乱。加版本号 乐观锁能解决大部分剩下的靠操作队列串行化。第四个是成本控制。记忆抽取本身也要调模型如果每轮都抽成本翻倍。我的优化是只在检测到「可能有新信息」时才触发抽取比如生成内容里出现了新的人名或状态词。这个启发式规则能省掉一大半抽取调用。这套架构我从零搭到能用大概花了两周业余时间。真正难的不是写代码是想清楚记忆该怎么组织、检索该怎么排序、冲突该怎么处理。这些设计决策一旦定错后面改起来伤筋动骨。所以如果你要动手建议先在纸上把记忆模型画清楚再写第一行代码。