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 Layer):Go 后端所在的位置。它负责根据当前写作任务,从记忆层捞出相关片段,组装成 prompt,调用模型,再把模型返回的内容解析回结构化数据写回记忆层。
- 交互层(Interaction Layer):React 前端。它把记忆可视化——角色关系图、时间线、伏笔状态,让作者能直观地看到「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 className="panel"> {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 的适应。不是所有模型都擅长读结构化输入。有的模型看到「【角色设定】」这种标记会当成正文写进去。解决办法是在系统提示里明确说明这是元信息,或者换成更自然的表述方式。这个得针对具体模型调。
第三个是前端状态同步的竞态。作者手速快的时候,可能在前一个记忆更新还没同步完就发起下一个操作,导致数据错乱。加版本号 + 乐观锁能解决大部分,剩下的靠操作队列串行化。
第四个是成本控制。记忆抽取本身也要调模型,如果每轮都抽,成本翻倍。我的优化是只在检测到「可能有新信息」时才触发抽取,比如生成内容里出现了新的人名或状态词。这个启发式规则能省掉一大半抽取调用。
这套架构我从零搭到能用,大概花了两周业余时间。真正难的不是写代码,是想清楚记忆该怎么组织、检索该怎么排序、冲突该怎么处理。这些设计决策一旦定错,后面改起来伤筋动骨。所以如果你要动手,建议先在纸上把记忆模型画清楚,再写第一行代码。