[{"data":1,"prerenderedAt":1869},["ShallowReactive",2],{"\u002F2026-07-08":3,"\u002F2026-07-08-rel":737},{"id":4,"title":5,"body":6,"column":721,"date":722,"description":12,"extension":723,"hero_image":724,"meta":725,"navigation":214,"path":726,"seo":727,"series_id":724,"severity":724,"stem":728,"summary":729,"tags":730,"__hash__":736},"posts\u002F2026-07-08-视频包装与数字人配音.md","配音时长不可控——那就让画面跟着它走",{"type":7,"value":8,"toc":712},"minimark",[9,13,18,26,29,32,69,72,86,89,92,99,284,297,300,312,315,318,325,336,379,386,389,400,408,417,420,431,434,437,443,478,484,516,522,539,542,548,585,591,610,615,644,647,650,653,656,670,673,676,679,698,701,708],[10,11,12],"p",{},"数字人口播的完整链路已能生成对口型视频原片，但从音频出发反向约束画面节奏、再分层合成包装成可发布的成片，这一环涉及三个工程难点，都不是生成问题，而是一致性与路径确定性问题。",[14,15,17],"h2",{"id":16},"tts-时长驱动的反向工作流","TTS 时长驱动的反向工作流",[10,19,20,21,25],{},"MiMo TTS 客户端同步调用返回 base64 音频与实际音长（单位秒），这个时长是后续所有操作的源头。关键约束在于",[22,23,24],"strong",{},"时长由上游决定，不能人为指定","。",[10,27,28],{},"拿一个具体例子：用户输入 60 字的口播文案，送给 MiMo preset 模式（冰糖音色、wav 格式）。MiMo 返回的不是\"这段话应该播 30 秒\"，而是\"我合成出来的音频实际是 28.5 秒\"。TTS 生成的时长由语速、音色、模型版本等因素共同决定，变量太多了。",[10,30,31],{},"在这个约束下，整个包装流程必须反向适配：",[33,34,35,47,60],"ol",{},[36,37,38,41,42,46],"li",{},[22,39,40],{},"TTS 合成音频"," → 得到 ",[43,44,45],"code",{},"audioDurationSec","（来自 ffprobe 或 MiMo 返回）",[36,48,49,52,53,55,56,59],{},[22,50,51],{},"飞天对口型"," → 输入 ",[43,54,45],{},"，输出\"这个长度的人物口播视频\"（飞天返回 ",[43,57,58],{},"duration","）",[36,61,62,65,66,68],{},[22,63,64],{},"HyperFrames 包装"," → 模板中的时间线也以这个 ",[43,67,58],{}," 为基准构建字幕、转场、片头片尾的时间点",[10,70,71],{},"如果反过来做——先定好画面框架是 60 秒，再想办法让音频往里塞——就陷入了\"剪音频、变速播放、或者音视不同步\"的泥潭。",[10,73,74,75,78,79,82,83,85],{},"实现上，MiMo 音频落地后先上传我方 S3，用 ffprobe 测长作为权威值（",[43,76,77],{},"probeVideoDurationSec"," 对音频也适用），这个测值再被传给飞天 ",[43,80,81],{},"create_by_audio"," 接口。飞天不是按输入时长生成固定时长的视频，而是真正根据音轨长度对口型——返回的 ",[43,84,58],{}," 几乎就等于输入的音频时长（考虑 AAC 编码器的 priming delay，实测偏差 0.64 帧即 21.33ms，可忽略）。",[14,87,88],{"id":88},"数字人作为独立图层而非重新生成",[10,90,91],{},"一个常见的工程误区：既然最终成片是\"数字人 + 包装\"，能不能让 HyperFrames 直接去生成数字人？不能。飞天数字人是外部供应商接口，响应慢（异步任务，等待 2-5 分钟常态），接口也不开放生成能力给外部工具（只提供对口型 API）。再者，用户可能想用同一份配音试多个数字人形象、或试多个包装模板，重新生成整条视频的成本太高。",[10,93,94,95,98],{},"设计决策是把飞天原片（已完成对口型的 MP4 文件）当作 HyperFrames composition 里的一个 ",[43,96,97],{},"\u003Cvideo>"," 图层。模板里声明这样的结构：",[100,101,106],"pre",{"className":102,"code":103,"language":104,"meta":105,"style":105},"language-html shiki shiki-themes github-light github-dark","\u003Cdiv data-track-index=\"0\">\n  \u003Cvideo data-start=\"0s\" data-duration=\"$MAIN_VIDEO_DURATION\" \n          data-has-audio=\"true\" \n          src=\"$MAIN_VIDEO_URL\">\n  \u003C\u002Fvideo>\n\u003C\u002Fdiv>\n\n\u003Cdiv data-track-index=\"1\">\n  \u003C!-- 字幕、花字、角标等包装层 -->\n\u003C\u002Fdiv>\n\n\u003Cdiv data-track-index=\"2\">\n  \u003C!-- 片头片尾、转场 -->\n\u003C\u002Fdiv>\n","html","",[43,107,108,135,163,176,189,199,209,216,232,239,248,253,269,275],{"__ignoreMap":105},[109,110,113,117,121,125,128,132],"span",{"class":111,"line":112},"line",1,[109,114,116],{"class":115},"sVt8B","\u003C",[109,118,120],{"class":119},"s9eBZ","div",[109,122,124],{"class":123},"sScJk"," data-track-index",[109,126,127],{"class":115},"=",[109,129,131],{"class":130},"sZZnC","\"0\"",[109,133,134],{"class":115},">\n",[109,136,138,141,144,147,149,152,155,157,160],{"class":111,"line":137},2,[109,139,140],{"class":115},"  \u003C",[109,142,143],{"class":119},"video",[109,145,146],{"class":123}," data-start",[109,148,127],{"class":115},[109,150,151],{"class":130},"\"0s\"",[109,153,154],{"class":123}," data-duration",[109,156,127],{"class":115},[109,158,159],{"class":130},"\"$MAIN_VIDEO_DURATION\"",[109,161,162],{"class":115}," \n",[109,164,166,169,171,174],{"class":111,"line":165},3,[109,167,168],{"class":123},"          data-has-audio",[109,170,127],{"class":115},[109,172,173],{"class":130},"\"true\"",[109,175,162],{"class":115},[109,177,179,182,184,187],{"class":111,"line":178},4,[109,180,181],{"class":123},"          src",[109,183,127],{"class":115},[109,185,186],{"class":130},"\"$MAIN_VIDEO_URL\"",[109,188,134],{"class":115},[109,190,192,195,197],{"class":111,"line":191},5,[109,193,194],{"class":115},"  \u003C\u002F",[109,196,143],{"class":119},[109,198,134],{"class":115},[109,200,202,205,207],{"class":111,"line":201},6,[109,203,204],{"class":115},"\u003C\u002F",[109,206,120],{"class":119},[109,208,134],{"class":115},[109,210,212],{"class":111,"line":211},7,[109,213,215],{"emptyLinePlaceholder":214},true,"\n",[109,217,219,221,223,225,227,230],{"class":111,"line":218},8,[109,220,116],{"class":115},[109,222,120],{"class":119},[109,224,124],{"class":123},[109,226,127],{"class":115},[109,228,229],{"class":130},"\"1\"",[109,231,134],{"class":115},[109,233,235],{"class":111,"line":234},9,[109,236,238],{"class":237},"sJ8bj","  \u003C!-- 字幕、花字、角标等包装层 -->\n",[109,240,242,244,246],{"class":111,"line":241},10,[109,243,204],{"class":115},[109,245,120],{"class":119},[109,247,134],{"class":115},[109,249,251],{"class":111,"line":250},11,[109,252,215],{"emptyLinePlaceholder":214},[109,254,256,258,260,262,264,267],{"class":111,"line":255},12,[109,257,116],{"class":115},[109,259,120],{"class":119},[109,261,124],{"class":123},[109,263,127],{"class":115},[109,265,266],{"class":130},"\"2\"",[109,268,134],{"class":115},[109,270,272],{"class":111,"line":271},13,[109,273,274],{"class":237},"  \u003C!-- 片头片尾、转场 -->\n",[109,276,278,280,282],{"class":111,"line":277},14,[109,279,204],{"class":115},[109,281,120],{"class":119},[109,283,134],{"class":115},[10,285,286,289,290,292,293,296],{},[43,287,288],{},"data-has-audio=\"true\""," 这一行是命门——没有它，渲染器会给 ",[43,291,97],{}," 默认加 ",[43,294,295],{},"muted","，成片就没声音。",[10,298,299],{},"这样做的好处显而易见：",[301,302,303,306,309],"ul",{},[36,304,305],{},"数字人原片一旦生成就不动，支持单独重做配音（只需重新调用 TTS 和飞天，不影响包装渲染）",[36,307,308],{},"同一个配音可套多个模板，不需要重新对口型",[36,310,311],{},"包装层的 HTML\u002FCSS 改动不会触发数字人重生成，迭代快",[10,313,314],{},"缺点是需要镜像内内置 Chromium 和 FFmpeg（官方渲染镜像实测 3.72GB），与 API 镜像分离部署。但这换来的是确定性输出和可控的环境——生产机、开发机、CI 跑同一个 composition，出片应该帧级一致（除了字体、系统库这类版本差异导致的微调，都锁版本了）。",[14,316,317],{"id":317},"成片路径的幂等性与状态机",[10,319,320,321,324],{},"用户选定模板、确认方案后，系统提交一个 ",[43,322,323],{},"VideoRenderJob","：输入模板 ID、原片 key、字幕 cues、音频 URL 等，输出成片 objectKey。同一份输入如果重试或重新提交，必须得到同样的输出（或者快速失败）。",[10,326,327,328,331,332,335],{},"状态流转是 ",[43,329,330],{},"queued → preparing → rendering → uploading → completed"," 或 ",[43,333,334],{},"failed","：",[301,337,338,348,354,368,374],{},[36,339,340,343,344,347],{},[22,341,342],{},"queued","：任务入队，等待 worker 消费。这一步是防并发上限的信号量检查——飞天有并发限制（错误码 1001），Redis 信号量 ",[43,345,346],{},"yunclaude:dub:sky:sem"," 兜底（触顶不直接失败，改为入队等待）。",[36,349,350,353],{},[22,351,352],{},"preparing","：下载原片、组装模板（注入数据、渲染占位符）。这一步的幂等性来自 objectKey 的内容寻址——同一个原片 key、同一个模板版本，组装出的 composition.html 比特级相同。",[36,355,356,359,360,363,364,367],{},[22,357,358],{},"rendering","：Chromium 逐帧捕获、FFmpeg 合成。这是确定性的关键——环境锁定（Node 24、Chromium 版本、FFmpeg 版本、字体版本都在镜像里写死），同一个 composition 渲染多次输出帧级一致。M1 POC 中用 seek 点验证：",[43,361,362],{},"t=4.5s→第 135 帧","、",[43,365,366],{},"t=30.0s→第 900 帧","（读原片内置计数器，nb_frames=1800），长时间点零漂移。",[36,369,370,373],{},[22,371,372],{},"uploading","：上传 OSS，记录 objectKey。返回给前端时用现签（每次读时重新签，不存短时 URL）。",[36,375,376,378],{},[22,377,334],{},"：任何一步异常，立即 rollback。计费侧已扣的视频点全额退款（resource operationId 幂等）。",[10,380,381,382,385],{},"失败的兜底是 reaper（BullMQ 的死信队列处理），轮询 ",[43,383,384],{},"status=running"," 超期（>10 分钟）的任务，标记为失败并补退款。",[14,387,388],{"id":388},"音画同步与字幕对齐",[10,390,391,392,395,396,399],{},"成片里字幕何时出现、何时消失，这些时间点由分析步生成的 ",[43,393,394],{},"subtitleCues"," 定义（格式 WebVTT）。一个 cue 的结构是 ",[43,397,398],{},"start → end"," + 文本，例如：",[100,401,406],{"className":402,"code":404,"language":405},[403],"language-text","00:05.000 --> 00:08.500\n这是一段口播文案\n","text",[43,407,404],{"__ignoreMap":105},[10,409,410,411,413,414,416],{},"HyperFrames 的字幕图层根据这些 cues 生成动画：start 时刻淡入，end 时刻淡出。整个成片的时间线参考都来自主视频（",[43,412,97],{}," 元素），而主视频的时长就是飞天返回的 ",[43,415,58],{},"——由音频长度决定。",[10,418,419],{},"一个细节：AAC-LC 编码器有 priming delay（约 1024 samples@48kHz = 21.33ms），所以成片里音频起点和视频起点存在一个已知的 21ms 偏移。但这是编码器层的常数，不是渲染问题，接受即可。",[10,421,422,423,426,427,430],{},"关键词高亮（比如把卖点词着色为黄色）需要在分析时做标记，例如 HTML 标签：",[43,424,425],{},"\u003Cspan class=\"highlight\">关键词\u003C\u002Fspan>","，然后 CSS 定义颜色。模板编写规约里明确禁止动画 ",[43,428,429],{},"letterSpacing"," 等布局属性（会在逐帧捕获时 snap 到整数像素产生抖动），只允许 transform（x\u002Fy\u002Fscale\u002Fopacity）。",[14,432,433],{"id":433},"成片路径的端到端烟测",[10,435,436],{},"发版前的验收分六个阶段：",[10,438,439,442],{},[22,440,441],{},"A. 发版前置","（缺一项线上就炸）",[301,444,445,456,462,472,475],{},[36,446,447,448,451,452,455],{},"后台已配渲染单价（resourceKey ",[43,449,450],{},"video_render_sec","），且 ",[43,453,454],{},"enabled"," 勾选",[36,457,458,459,59],{},"灰度名单已配置（环境变量 ",[43,460,461],{},"VIDEO_RENDER_HTML_USER_IDS",[36,463,464,465,468,469,59],{},"发版机 ",[43,466,467],{},"release.sh"," 已改（支持第 6 个镜像 ",[43,470,471],{},"yc-video-render",[36,473,474],{},"K8s 集群配额已提升（requestQuota 新增 2C\u002F2Gi、limitsQuota 新增 4C\u002F4Gi）",[36,476,477],{},"构建机磁盘充足（≥10GB 空闲）",[10,479,480,483],{},[22,481,482],{},"B. 发版后基础设施","（不通过立即 rollout undo）",[301,485,486,492,499,506,513],{},[36,487,488,489,491],{},"迁移已执行（",[43,490,323],{}," 表已建）",[36,493,494,495,498],{},"渲染 worker 已起（",[43,496,497],{},"READY 1\u002F1","，镜像 tag 与本次发版一致）",[36,500,501,502,505],{},"容器内 hyperframes CLI 可用（",[43,503,504],{},"hyperframes --version"," 返回 0.7.70）",[36,507,508,509,512],{},"容器内中文字体已装（",[43,510,511],{},"fc-list :lang=zh"," 非空）",[36,514,515],{},"worker 连上 Redis 队列（日志无 crash loop）",[10,517,518,521],{},[22,519,520],{},"C. 回归防线：未放量用户零感知","（最高优先级）",[301,523,524,527,530,533],{},[36,525,526],{},"不在灰度名单的用户进增强步看不到\"包装模板\"卡",[36,528,529],{},"旧链路（ffmpeg）完整出片，字幕\u002F转场\u002FBGM 都在",[36,531,532],{},"旧链路仍生成 AI 插片，计费时间线出现 seedance 扣费",[36,534,535,536,538],{},"未放量时 ",[43,537,323],{}," 表没有新行",[10,540,541],{},"这段最重要是因为旧链路服务着所有现存数字人用户。一个新功能开关不应该波及灰度外的用户。",[10,543,544,547],{},[22,545,546],{},"D. 灰度用户正向流程","（核心价值）",[301,549,550,553,560,567,570,573,576,579,582],{},[36,551,552],{},"模板列表可见：增强步看到\"不加包装\"+\"美食探店\"两张卡",[36,554,555,556,559],{},"选模板后出片最终 ",[43,557,558],{},"completed","，可播放",[36,561,562,563,566],{},"成片有口播声音（这是 ",[43,564,565],{},"data-has-audio"," 命门检验）",[36,568,569],{},"片头\u002F片尾\u002F角标都在：0-3s 品牌片头、右上角全程角标、最后 3s CTA",[36,571,572],{},"中文不乱码：片头标题与字幕汉字正常",[36,574,575],{},"字幕关键词高亮：关键词黄色，其余白色，标签没被打碎",[36,577,578],{},"进度实时可见：出片过程中进度条推进，不死在一个数字",[36,580,581],{},"成片链接可下载且长期有效：15 分钟后刷新页面仍能播放",[36,583,584],{},"渲染时长符合预期：60s 成片≤5 分钟（M1 POC 实测 39.5s）",[10,586,587,590],{},[22,588,589],{},"E. 资金正确性","（看后台账单，不能只看页面）",[301,592,593,598,601,604,607],{},[36,594,595,596,59],{},"扣的是视频点不是算力点（计费时间线条目 title 首段是 ",[43,597,143],{},[36,599,600],{},"不再扣 seedance 插片钱（灰度用户的时间线里没有插片扣费）",[36,602,603],{},"结算按实际秒数（units ≈ 成片时长整秒向上取整）",[36,605,606],{},"余额不足时不产生任务、不扣费",[36,608,609],{},"重复提交不重复扣费（operationId 幂等）",[10,611,612],{},[22,613,614],{},"F. 异常路径",[301,616,617,623,629,632,635,638],{},[36,618,619,620,622],{},"渲染失败会退款：任务置 ",[43,621,334],{},"，计费时间线出现退款条目",[36,624,625,626,628],{},"失败同步反映到增强任务：增强任务也变 ",[43,627,334],{},"，前端不会永远停在\"渲染中\"",[36,630,631],{},"排队中可取消并全额退：返回成功，退款到账",[36,633,634],{},"已在渲染中不可取消：返回 409，不退款（CPU 已烧）",[36,636,637],{},"worker 重启不丢任务：任务被重新捞起或置失败退款",[36,639,640,641,643],{},"超时判失败：>10 分钟置 ",[43,642,334],{}," 并退款",[10,645,646],{},"每一条都写了具体的检查命令和判据。比如验证成片有声音，就是直接播放、耳朵听；验证字幕关键词高亮，就是肉眼看颜色；验证退款，就是对比出片前后的计费时间线。",[14,648,649],{"id":649},"设计的权衡",[10,651,652],{},"这套方案的成本是什么？",[10,654,655],{},"首先是镜像大小：官方渲染镜像装了 280 多个 apt 包、node_modules、Chromium 和 FFmpeg，构建出的镜像实测 3.72GB，单独占用一个 K8s node pool，构建时间约 10 分钟，推拉镜像耗时显著上升。但这是\"要么装进 API 镜像肥到 6GB+、拖累三个部署，要么单独镜像\"的取舍——选了单独。",[10,657,658,659,661,662,665,666,669],{},"其次是模板编写规约的学习成本。一个看起来\"普通的网页动画\"可能在逐帧 seek 渲染时炸：GSAP 退场动画必须挂在 clip 内层并补 hard kill，禁止 ",[43,660,429],{}," 等布局属性，中文字体必须显式 ",[43,663,664],{},"@font-face","（容器内用 Noto Sans CJK）。",[43,667,668],{},"hyperframes check"," 作为模板上架的强制 gate，能一次性拦截这类问题。",[10,671,672],{},"获得的是什么？確定性输出。同一个模板、同一份配音，渲染 100 次得到 100 个帧级一致的成片（前提是输入稳定，不变数字人形象、不变字体库版本）。这对 CI 自动回归、成片质量审核都很有意义。还有灵活性：配音、数字人、包装模板可以独立迭代，不互相阻塞。",[14,674,675],{"id":675},"线上部署的最后两步",[10,677,678],{},"发版后的两个关键项，缺一项都会导致灰度用户无法下单：",[33,680,681,690],{},[36,682,683,335,686,689],{},[22,684,685],{},"后台配价",[43,687,688],{},"resourceKey=video_render_sec"," 的单价必须填（单位元\u002F秒），且勾选 enabled。没配的话 chargeResource 会返回 404，用户点开始出片就直接报错。",[36,691,692,335,695,697],{},[22,693,694],{},"灰度名单",[43,696,461],{}," 环境变量决定了谁能看到模板卡。留空 = 全员走旧 ffmpeg 链路（可用于紧急回滚），指定用户 ID = 该用户进入新链路。发版初期应只填 1-2 个测试账号。",[10,699,700],{},"这两个配置是代码之外的硬依赖，容易遗漏。烟测清单里放在最前面，作为\"不做后面全走不通\"的前置项。",[10,702,703,704,707],{},"整个方案的核心原则是",[22,705,706],{},"分层隔离与路径确定性","：TTS 决定时长、飞天决定人像、HyperFrames 决定包装，各层独立演进，成片路径从输入到输出一条流水线，无分支、无条件、无随机。这对一个产生可发布物料的流水线来说，是底线。",[709,710,711],"style",{},"html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":105,"searchDepth":137,"depth":137,"links":713},[714,715,716,717,718,719,720],{"id":16,"depth":137,"text":17},{"id":88,"depth":137,"text":88},{"id":317,"depth":137,"text":317},{"id":388,"depth":137,"text":388},{"id":433,"depth":137,"text":433},{"id":649,"depth":137,"text":649},{"id":675,"depth":137,"text":675},"内容流水线","2026-07-08","md",null,{},"\u002F2026-07-08",{"title":5,"description":12},"2026-07-08-视频包装与数字人配音","数字人口播成片的最后一环：配音时长不可控，如何反向驱动画面？原片如何作为独立图层无损合成？成片路径如何保证确定性？",[731,732,733,734,735],"TTS","数字人","HyperFrames","音视同步","流水线设计","ISg-n2hvjyCKUW_WMTk0Ais8ji7YhOK5rXPmNyUBWQk",[738,1216,1533],{"id":4,"title":5,"body":739,"column":721,"date":722,"description":12,"extension":723,"hero_image":724,"meta":1213,"navigation":214,"path":726,"seo":1214,"series_id":724,"severity":724,"stem":728,"summary":729,"tags":1215,"__hash__":736},{"type":7,"value":740,"toc":1204},[741,743,745,749,751,753,775,777,785,787,789,793,927,935,937,945,947,949,953,959,987,991,993,999,1004,1010,1012,1018,1020,1022,1026,1048,1052,1072,1076,1088,1090,1094,1118,1122,1136,1140,1160,1162,1164,1166,1168,1176,1178,1180,1182,1196,1198,1202],[10,742,12],{},[14,744,17],{"id":16},[10,746,20,747,25],{},[22,748,24],{},[10,750,28],{},[10,752,31],{},[33,754,755,761,769],{},[36,756,757,41,759,46],{},[22,758,40],{},[43,760,45],{},[36,762,763,52,765,55,767,59],{},[22,764,51],{},[43,766,45],{},[43,768,58],{},[36,770,771,65,773,68],{},[22,772,64],{},[43,774,58],{},[10,776,71],{},[10,778,74,779,78,781,82,783,85],{},[43,780,77],{},[43,782,81],{},[43,784,58],{},[14,786,88],{"id":88},[10,788,91],{},[10,790,94,791,98],{},[43,792,97],{},[100,794,795],{"className":102,"code":103,"language":104,"meta":105,"style":105},[43,796,797,811,831,841,851,859,867,871,885,889,897,901,915,919],{"__ignoreMap":105},[109,798,799,801,803,805,807,809],{"class":111,"line":112},[109,800,116],{"class":115},[109,802,120],{"class":119},[109,804,124],{"class":123},[109,806,127],{"class":115},[109,808,131],{"class":130},[109,810,134],{"class":115},[109,812,813,815,817,819,821,823,825,827,829],{"class":111,"line":137},[109,814,140],{"class":115},[109,816,143],{"class":119},[109,818,146],{"class":123},[109,820,127],{"class":115},[109,822,151],{"class":130},[109,824,154],{"class":123},[109,826,127],{"class":115},[109,828,159],{"class":130},[109,830,162],{"class":115},[109,832,833,835,837,839],{"class":111,"line":165},[109,834,168],{"class":123},[109,836,127],{"class":115},[109,838,173],{"class":130},[109,840,162],{"class":115},[109,842,843,845,847,849],{"class":111,"line":178},[109,844,181],{"class":123},[109,846,127],{"class":115},[109,848,186],{"class":130},[109,850,134],{"class":115},[109,852,853,855,857],{"class":111,"line":191},[109,854,194],{"class":115},[109,856,143],{"class":119},[109,858,134],{"class":115},[109,860,861,863,865],{"class":111,"line":201},[109,862,204],{"class":115},[109,864,120],{"class":119},[109,866,134],{"class":115},[109,868,869],{"class":111,"line":211},[109,870,215],{"emptyLinePlaceholder":214},[109,872,873,875,877,879,881,883],{"class":111,"line":218},[109,874,116],{"class":115},[109,876,120],{"class":119},[109,878,124],{"class":123},[109,880,127],{"class":115},[109,882,229],{"class":130},[109,884,134],{"class":115},[109,886,887],{"class":111,"line":234},[109,888,238],{"class":237},[109,890,891,893,895],{"class":111,"line":241},[109,892,204],{"class":115},[109,894,120],{"class":119},[109,896,134],{"class":115},[109,898,899],{"class":111,"line":250},[109,900,215],{"emptyLinePlaceholder":214},[109,902,903,905,907,909,911,913],{"class":111,"line":255},[109,904,116],{"class":115},[109,906,120],{"class":119},[109,908,124],{"class":123},[109,910,127],{"class":115},[109,912,266],{"class":130},[109,914,134],{"class":115},[109,916,917],{"class":111,"line":271},[109,918,274],{"class":237},[109,920,921,923,925],{"class":111,"line":277},[109,922,204],{"class":115},[109,924,120],{"class":119},[109,926,134],{"class":115},[10,928,929,289,931,292,933,296],{},[43,930,288],{},[43,932,97],{},[43,934,295],{},[10,936,299],{},[301,938,939,941,943],{},[36,940,305],{},[36,942,308],{},[36,944,311],{},[10,946,314],{},[14,948,317],{"id":317},[10,950,320,951,324],{},[43,952,323],{},[10,954,327,955,331,957,335],{},[43,956,330],{},[43,958,334],{},[301,960,961,967,971,979,983],{},[36,962,963,343,965,347],{},[22,964,342],{},[43,966,346],{},[36,968,969,353],{},[22,970,352],{},[36,972,973,359,975,363,977,367],{},[22,974,358],{},[43,976,362],{},[43,978,366],{},[36,980,981,373],{},[22,982,372],{},[36,984,985,378],{},[22,986,334],{},[10,988,381,989,385],{},[43,990,384],{},[14,992,388],{"id":388},[10,994,391,995,395,997,399],{},[43,996,394],{},[43,998,398],{},[100,1000,1002],{"className":1001,"code":404,"language":405},[403],[43,1003,404],{"__ignoreMap":105},[10,1005,410,1006,413,1008,416],{},[43,1007,97],{},[43,1009,58],{},[10,1011,419],{},[10,1013,422,1014,426,1016,430],{},[43,1015,425],{},[43,1017,429],{},[14,1019,433],{"id":433},[10,1021,436],{},[10,1023,1024,442],{},[22,1025,441],{},[301,1027,1028,1034,1038,1044,1046],{},[36,1029,447,1030,451,1032,455],{},[43,1031,450],{},[43,1033,454],{},[36,1035,458,1036,59],{},[43,1037,461],{},[36,1039,464,1040,468,1042,59],{},[43,1041,467],{},[43,1043,471],{},[36,1045,474],{},[36,1047,477],{},[10,1049,1050,483],{},[22,1051,482],{},[301,1053,1054,1058,1062,1066,1070],{},[36,1055,488,1056,491],{},[43,1057,323],{},[36,1059,494,1060,498],{},[43,1061,497],{},[36,1063,501,1064,505],{},[43,1065,504],{},[36,1067,508,1068,512],{},[43,1069,511],{},[36,1071,515],{},[10,1073,1074,521],{},[22,1075,520],{},[301,1077,1078,1080,1082,1084],{},[36,1079,526],{},[36,1081,529],{},[36,1083,532],{},[36,1085,535,1086,538],{},[43,1087,323],{},[10,1089,541],{},[10,1091,1092,547],{},[22,1093,546],{},[301,1095,1096,1098,1102,1106,1108,1110,1112,1114,1116],{},[36,1097,552],{},[36,1099,555,1100,559],{},[43,1101,558],{},[36,1103,562,1104,566],{},[43,1105,565],{},[36,1107,569],{},[36,1109,572],{},[36,1111,575],{},[36,1113,578],{},[36,1115,581],{},[36,1117,584],{},[10,1119,1120,590],{},[22,1121,589],{},[301,1123,1124,1128,1130,1132,1134],{},[36,1125,595,1126,59],{},[43,1127,143],{},[36,1129,600],{},[36,1131,603],{},[36,1133,606],{},[36,1135,609],{},[10,1137,1138],{},[22,1139,614],{},[301,1141,1142,1146,1150,1152,1154,1156],{},[36,1143,619,1144,622],{},[43,1145,334],{},[36,1147,625,1148,628],{},[43,1149,334],{},[36,1151,631],{},[36,1153,634],{},[36,1155,637],{},[36,1157,640,1158,643],{},[43,1159,334],{},[10,1161,646],{},[14,1163,649],{"id":649},[10,1165,652],{},[10,1167,655],{},[10,1169,658,1170,661,1172,665,1174,669],{},[43,1171,429],{},[43,1173,664],{},[43,1175,668],{},[10,1177,672],{},[14,1179,675],{"id":675},[10,1181,678],{},[33,1183,1184,1190],{},[36,1185,1186,335,1188,689],{},[22,1187,685],{},[43,1189,688],{},[36,1191,1192,335,1194,697],{},[22,1193,694],{},[43,1195,461],{},[10,1197,700],{},[10,1199,703,1200,707],{},[22,1201,706],{},[709,1203,711],{},{"title":105,"searchDepth":137,"depth":137,"links":1205},[1206,1207,1208,1209,1210,1211,1212],{"id":16,"depth":137,"text":17},{"id":88,"depth":137,"text":88},{"id":317,"depth":137,"text":317},{"id":388,"depth":137,"text":388},{"id":433,"depth":137,"text":433},{"id":649,"depth":137,"text":649},{"id":675,"depth":137,"text":675},{},{"title":5,"description":12},[731,732,733,734,735],{"id":1217,"title":1218,"body":1219,"column":721,"date":1519,"description":1223,"extension":723,"hero_image":724,"meta":1520,"navigation":214,"path":1521,"seo":1522,"series_id":724,"severity":724,"stem":1523,"summary":1524,"tags":1525,"__hash__":1532},"posts\u002F2026-07-02-工作流迁移与复用.md","搬一条生产线，难的不是业务逻辑",{"type":7,"value":1220,"toc":1508},[1221,1224,1227,1230,1234,1241,1244,1250,1253,1256,1263,1278,1281,1284,1301,1304,1323,1326,1329,1342,1345,1351,1377,1388,1391,1394,1397,1404,1407,1410,1413,1416,1423,1426,1455,1470,1473,1476,1502,1505],[10,1222,1223],{},"旧平台上已经跑通了漫剧和小说两条完整的创作工作流。新平台已具备图片生成、异步任务、用户鉴权和资源计费的基础设施。现在需要把这两条链路搬过来，但不是照搬代码，而是复用领域逻辑重新适配。",[10,1225,1226],{},"迁移看似是一个代码挪动的问题，实际上是一个架构解耦的问题。两个平台在三个维度上产生了紧密耦合，直接挪动代码会导致新平台继承旧平台的设计包袱。",[14,1228,1229],{"id":1229},"三处不兼容的耦合",[1231,1232,1233],"h3",{"id":1233},"任务队列与调度模型",[10,1235,1236,1237,1240],{},"旧平台的工作流采用进程内任务 Map 和命令式 API 网关。漫剧工作流从剧本拆分镜头、生成资产图、生成视频、拼接整集——这一系列操作都是通过 ",[43,1238,1239],{},"__api\u002Fcomic_*"," 这样的命令端点来驱动的，任务状态存在内存 Map 里，重启进程就丢了。",[10,1242,1243],{},"新平台设计了一套资源计费底座，任务需要经历预留（reserve）→ 执行 → 结算（settle）→ 失败时退款（refund）的流程。以小说工作流为例，每个阶段（设定、世界观、角色、分卷、大纲、章节）都是独立任务，生成前要冻结估算的算力点，生成后按实际输出字符数结算，多退少补。这套计费流程嵌入到新平台的 Billing 服务里，不能绕过。",[10,1245,1246,1247,25],{},"如果我直接把旧平台的任务调度逻辑搬过来，新平台的任务会跳过 reserve 这一步，造成\"已生成但余额不足无法扣费\"的问题。同样，旧平台的视频任务如果失败只是标记 failed，没有退款逻辑，新平台则需要原子地调用 ",[43,1248,1249],{},"RefundCharge",[10,1251,1252],{},"两边的任务模型在原语层面就不兼容。",[1231,1254,1255],{"id":1255},"存储路径与对象约定",[10,1257,1258,1259,1262],{},"旧平台用本地 JSON 文件存储小说项目数据。小说作品、设定、世界观、角色这些结构化阶段的原始输出被序列化到磁盘上，依赖一套约定好的目录结构。漫剧项目也类似，源项目中通过 ",[43,1260,1261],{},"featureDir(userId, 'comic')"," 这样的函数来确定资产文件的存储根路径。",[10,1264,1265,1266,1269,1270,1273,1274,1277],{},"新平台统一使用 S3 \u002F OOS 对象存储加 Prisma 数据库。项目的元数据（作品标题、阶段状态、版本历史）存 Prisma 表，生成的内容（图片、视频、结构化文本）存对象存储并记录 key。例如小说的 ",[43,1267,1268],{},"NovelSection"," 表存储化后的 JSON 结构和展示文本，章节正文存在 ",[43,1271,1272],{},"NovelChapterVersion"," 表的 ",[43,1275,1276],{},"content"," 字段。",[10,1279,1280],{},"如果我从旧平台直接挪过来一套\"读本地 JSON\"的逻辑，新平台就要维护两套存储系统。更重要的是，新平台的计费系统依赖持久化的结构化数据——只有把规范化后的展示文本存进表里，后台才能追溯某次扣费对应的具体内容。",[1231,1282,1283],{"id":1283},"计费模型与资源定价",[10,1285,1286,1287,363,1290,1293,1294,331,1297,1300],{},"旧平台对小说的计费可能是按生成 token 次数、按模型调用、或者干脆不计费。新平台设计了一套通用资源计费框架：每个资源有一个 key（例如 ",[43,1288,1289],{},"image_generation",[43,1291,1292],{},"novel_text_output","），配套一个定价模型（",[43,1295,1296],{},"PER_CALL",[43,1298,1299],{},"PER_UNIT","），后台可以随时调价。",[10,1302,1303],{},"对小说工作流，新平台的选择是按可见字符数计费，不按 token。这意味着：",[301,1305,1306,1309,1316],{},[36,1307,1308],{},"用户最终看到的文本去掉空白后的字符数才会被计入。",[36,1310,1311,1312,1315],{},"模型原始输出如果是 JSON 格式，那么 ",[43,1313,1314],{},"{}"," 括号、字段名、引号、逗号、缩进都不算——只计算最终展示的内容。",[36,1317,1318,1319,1322],{},"每个阶段的计费单位是 ",[43,1320,1321],{},"算力点 \u002F 千字","，后台可配置，用户无感知。",[10,1324,1325],{},"从旧平台的某种计费方式切换到这套新模型，需要重新梳理每个环节的计费触发点。如果只是把旧的生成函数搬过来调用一遍，新平台的 Billing 根本没有机会介入。",[14,1327,1328],{"id":1328},"迁移方案的抽象层次",[10,1330,1331,1332,363,1335,363,1338,1341],{},"面对这三处耦合，如果采取\"完全抽象\"的思路——把工作流定义成一个通用的步骤编排引擎，每个平台只需要实现 ",[43,1333,1334],{},"execute_step",[43,1336,1337],{},"store_result",[43,1339,1340],{},"charge_resource"," 这样的抽象接口——理论上很优雅，但代价是引入了一层不必要的复杂性。通用引擎要支持各种平台的差异，势必要留下很多可配置项，导致理解和维护难度上升。",[10,1343,1344],{},"实际采取的方案是有选择的复用：",[10,1346,1347,1350],{},[22,1348,1349],{},"复用稳定部分","：工作流的步骤编排和领域逻辑。小说工作流的七个阶段顺序（设定 → 宏观 → 世界观 → 角色 → 分卷 → 拆章 → 正文）是领域知识，与平台无关。这套提示词组装、JSON 解析、规范化、展示文本提取的逻辑，从旧平台完整迁移到新平台，TypeScript 化但不改核心算法。漫剧工作流的四阶段（剧本 → 资产 → 分镜 → 成片）也是如此。",[10,1352,1353,1356,1357,363,1360,363,1362,363,1365,1368,1369,1372,1373,1376],{},[22,1354,1355],{},"重新实现易变部分","：任务调度、存储、计费。小说模块新增 ",[43,1358,1359],{},"NovelProject",[43,1361,1268],{},[43,1363,1364],{},"NovelChapter",[43,1366,1367],{},"NovelTask"," 等 Prisma 模型，完全按新平台的设计来。生成任务在开始前调用 ",[43,1370,1371],{},"billing.reserveResource()","，成功后提取展示文本、调用 ",[43,1374,1375],{},"billing.settleResource()","，失败时退款。这套接口与 Image Generation 任务的流程完全一致，在平台已有的基础上建造。",[10,1378,1379,1380,1383,1384,1387],{},"具体的模型调用、LLM 的参数、生成的超时策略这些\"具体模型调用\"的细节，留在各自的适配层。例如小说模块的 ",[43,1381,1382],{},"novel-generation.ts"," 只负责组装提示词、调用 LLM、解析结果，不涉及数据库操作；数据库操作交给 ",[43,1385,1386],{},"novel-service.ts","。这样领域逻辑与平台逻辑的边界清晰，后续不同平台可以并行维护。",[14,1389,1390],{"id":1390},"关键的取舍决定",[10,1392,1393],{},"完全抽象的诱惑在于\"一套代码支持多平台\"的承诺。但这需要引入足够的可配置性和接口设计，而代价是灵活性反而下降——当某个平台需要特殊处理某个步骤时，通用引擎不得不打补丁。这在两个平台差异较大的情况下尤其成立。",[10,1395,1396],{},"选择有针对性的复用，意味着承认重复：数据模型要各写一份，任务调度逻辑要各写一份。但这个重复是可控的，因为它们在各自平台内部是一致的。小说模块的任务预留\u002F结算逻辑完全复用 Image Generation 已有的那套，不需要新增抽象层。",[10,1398,1399,1400,1403],{},"另一个关键的取舍是用户模型的配置。旧平台可能让用户选择小说生成用哪个模型，新平台则完全隐藏模型选择，由后台配置或环境变量决定。这简化了前端和 API 的设计——用户请求体里根本不接受 ",[43,1401,1402],{},"model"," 字段，Billing 也不需要按模型定价。代价是失去了\"用户自行选择成本与质量的平衡\"的灵活性，但换来了计费模型的清晰和后台的可控。",[10,1405,1406],{},"这类决定需要在迁移前明确：哪些能力是目标平台\"必须有\"的，哪些是\"很有但可以先不做\"的。漫剧工作流迁移时明确排除了白模（3D 白模式）视频相关的所有功能，不复制 Blender 依赖、不暴露白模提示词输入、成片阶段只处理普通视频。这个决定减少了迁移的复杂性，也避免了在新平台上重新部署 Blender 和白模渲染的基础设施。",[14,1408,1409],{"id":1409},"稳定部分与易变部分的边界",[10,1411,1412],{},"这个分法的关键在于，稳定部分要确实稳定。领域逻辑（各阶段的提示词、章节的上下文组装、长篇的记忆管理）在两个平台上是一样的，因为它反映的是小说创作或漫剧创作的规律。但是，一旦涉及\"这个阶段的输入从哪里读、输出存到哪里、失败后怎么处理\"，就已经是平台相关的。",[10,1414,1415],{},"以小说的世界观生成为例：",[10,1417,1418,1419,1422],{},"稳定部分是 ",[43,1420,1421],{},"worldPrompt(previousSections, projectSettings)"," 这个函数，它组装 LLM 提示词，描述\"根据设定和前序阶段的输出，生成世界观\"。这个函数与平台无关。",[10,1424,1425],{},"易变部分是：",[301,1427,1428,1435,1438,1448],{},[36,1429,1430,1431,1434],{},"生成前如何预留算力点（新平台调 ",[43,1432,1433],{},"billing.reserveResource","，旧平台可能不调）",[36,1436,1437],{},"生成结果是一个 JSON 对象，如何从中提取展示文本（两个平台的数据模型可能不同，但提取逻辑应该是一样的——属于稳定部分）",[36,1439,1440,1441,1444,1445,59],{},"提取后的展示文本存到哪里（新平台的 ",[43,1442,1443],{},"NovelSection.displayText"," 字段，旧平台可能是本地文件 ",[43,1446,1447],{},"sections\u002Fworld.json",[36,1449,1450,1451,1454],{},"失败时如何处理（新平台调 ",[43,1452,1453],{},"billing.refundResource","，旧平台可能是清除临时文件）",[10,1456,1457,1458,363,1461,1463,1464,363,1466,1469],{},"这样划分后，新平台只需要在适配层（",[43,1459,1460],{},"novel-routes.ts",[43,1462,1386],{},"）重新实现存储和计费部分，核心生成逻辑（",[43,1465,1382],{},[43,1467,1468],{},"novel-prompts.ts","）从旧平台迁移过来。",[14,1471,1472],{"id":1472},"实际的迁移清单",[10,1474,1475],{},"从这个分析，迁移的具体工作清单变成：",[33,1477,1478,1484,1490,1496],{},[36,1479,1480,1483],{},[22,1481,1482],{},"评估可复用的代码"," — 在旧平台找出真正与平台无关的部分。对小说工作流，这包括提示词、JSON 解析、规范化逻辑。对漫剧工作流，这包括分镜拆分的逻辑、资产提取的规则。",[36,1485,1486,1489],{},[22,1487,1488],{},"定义新平台的合同"," — 每个工作流步骤的输入输出是什么，资源消耗如何计量。小说阶段的输出是规范化 JSON 加展示文本，消耗的资源是可见字符数。漫剧镜头的输出是镜头配置和首帧图片，消耗的资源是视频秒数（按模型和分辨率估算）。",[36,1491,1492,1495],{},[22,1493,1494],{},"实现平台适配层"," — 数据模型（Prisma 表）、任务调度（与 Billing 集成）、错误处理和重试。这部分代码是新平台特有的，不能也不应该复用。",[36,1497,1498,1501],{},[22,1499,1500],{},"测试边界"," — 在适配层写测试，验证计费逻辑、任务状态机、错误恢复。在稳定部分写测试，验证生成质量、规范化正确性。",[10,1503,1504],{},"不采用这样的分层，而是直接把旧平台的 Next.js 路由、本地文件操作、命令式 API 端点搬到新平台，结果是新平台沦为旧平台代码的容器。后续需要升级某个依赖、调整计费模型、或对接新的生成模型时，都会发现改一个地方影响到另一个地方。",[10,1506,1507],{},"设置清晰的边界，允许有选择的重复，是避免这个问题的方法。",{"title":105,"searchDepth":137,"depth":137,"links":1509},[1510,1515,1516,1517,1518],{"id":1229,"depth":137,"text":1229,"children":1511},[1512,1513,1514],{"id":1233,"depth":165,"text":1233},{"id":1255,"depth":165,"text":1255},{"id":1283,"depth":165,"text":1283},{"id":1328,"depth":137,"text":1328},{"id":1390,"depth":137,"text":1390},{"id":1409,"depth":137,"text":1409},{"id":1472,"depth":137,"text":1472},"2026-07-02",{},"\u002F2026-07-02",{"title":1218,"description":1223},"2026-07-02-工作流迁移与复用","两条生产工作流从旧平台迁移到新平台，难点不在业务逻辑重写，而在解耦三处平台依赖——任务调度、存储约定、计费接口。",[1526,1527,1528,1529,1530,1531],"架构","工作流","微服务","平台化","任务队列","计费系统","zEJUEqZgsQkHJOxHWOoc4WL1iYD_NaAli4VZYCye-ZY",{"id":1534,"title":1535,"body":1536,"column":721,"date":1855,"description":1540,"extension":723,"hero_image":724,"meta":1856,"navigation":214,"path":1857,"seo":1858,"series_id":724,"severity":724,"stem":1859,"summary":1860,"tags":1861,"__hash__":1868},"posts\u002F2026-06-25-长文写作的记忆分层.md","三十万字之后，AI 该记住什么？",{"type":7,"value":1537,"toc":1846},[1538,1541,1544,1547,1550,1553,1556,1559,1563,1566,1572,1580,1586,1591,1597,1602,1608,1613,1624,1627,1631,1634,1640,1645,1651,1656,1661,1666,1672,1677,1683,1688,1691,1694,1698,1701,1704,1707,1710,1713,1720,1758,1769,1780,1783,1786,1795,1804,1817,1830,1833,1840,1843],[10,1539,1540],{},"长篇小说写到三十万字后，\"记住前面写了什么\"变成最大的瓶颈。角色在第五章确立的设定，到第二十章突然对不上；伏笔埋了二十章没回收；世界观里的时间线出现逻辑洞；前面说过的势力关系后面又改了。全量注入上一章或全书到上下文里根本不可能，纯向量检索又容易因为相似度不够而漏掉关键的硬约束。",[10,1542,1543],{},"这个问题需要分层的记忆系统。根据信息的稳定性和用途把记忆分成三层：设定层、事实层、文本层。每层的存储方式和检索策略完全不同。",[14,1545,1546],{"id":1546},"为什么不能统一用向量库",[10,1548,1549],{},"一个很自然的想法是\"把所有信息存到向量库里，需要时向量检索\"。但设定信息不行。",[10,1551,1552],{},"世界规则、人物档案这类信息是硬约束，一旦因为向量相似度不够高而没被召回，就会产生直接的设定冲突。模型可能生成\"主角这章掌握了某个禁忌知识\"，但系统没召回\"世界规则里明确说这个知识是自杀性的\"，结果后续剧情完全跑偏。向量检索是概率性的，概率性检索不能承载硬约束。",[10,1554,1555],{},"事实信息（已发生的事件、角色状态变化、伏笔）可以用向量检索，因为\"不完美的匹配\"还能通过更多文本从模型推理出来。如果系统检索到\"角色在某章失去了一条胳膊\"这个事实后又检索到\"这个事件在剧情中的含义是……\"，模型有足够的上下文修复漏掉的细节。",[10,1557,1558],{},"原文（正文片段）是纯量级的数据，全量注入的成本太高，用摘要替代是更经济的做法。",[14,1560,1562],{"id":1561},"设定层结构化存储全量或定向注入","设定层：结构化存储、全量或定向注入",[10,1564,1565],{},"设定层装的是世界级的硬规则和静态档案，包括：",[10,1567,1568,1571],{},[22,1569,1570],{},"世界规则","（约 600 字压缩）",[301,1573,1574,1577],{},[36,1575,1576],{},"现实是否稳定、超常能力是否公开、死亡是否可逆、信息获取是否受限",[36,1578,1579],{},"这个世界\"允许什么、不允许什么\"",[10,1581,1582,1585],{},[22,1583,1584],{},"系统性规则","（通常 4-6 条）",[301,1587,1588],{},[36,1589,1590],{},"力量体系的上限、交易与代价的原理、禁忌知识的危害方式",[10,1592,1593,1596],{},[22,1594,1595],{},"主要势力","（通常 3-5 个）",[301,1598,1599],{},[36,1600,1601],{},"每个势力的名称、目标、常用手段、与其他势力的关系",[10,1603,1604,1607],{},[22,1605,1606],{},"核心人物档案","（通常 4-8 个）",[301,1609,1610],{},[36,1611,1612],{},"每个人物的身份、核心目标、掌握的信息、心理底线",[10,1614,1615,1616,1619,1620,1623],{},"这些东西在整部小说中是不变的（或变化极缓），写作的任何环节都需要遵循它们。设定层必须在调用 AI 生成章节时",[22,1617,1618],{},"全量注入","或",[22,1621,1622],{},"按需定向注入","。全量注入适合小说世界相对简洁的情况；如果世界复杂设定众多，可以做定向注入——比如这一章涉及势力 A，就只注入 A 的档案和相关规则。",[10,1625,1626],{},"关键是：设定层的记忆片段永远不能因为上下文窗口压力而被省略。这是整个系统的天花板。",[14,1628,1630],{"id":1629},"事实层向量检索-时间过滤","事实层：向量检索 + 时间过滤",[10,1632,1633],{},"事实层装的是已经发生的、会影响后续剧情的信息。",[10,1635,1636,1639],{},[22,1637,1638],{},"章节摘要","（每章一句话）",[301,1641,1642],{},[36,1643,1644],{},"\"主角从势力 B 得到了关键物品 X，但同时被势力 A 注意到了\"",[10,1646,1647,1650],{},[22,1648,1649],{},"角色动态状态","（追加式、非覆盖）",[301,1652,1653],{},[36,1654,1655],{},"某角色掌握了什么新信息、失去了什么能力、和某人的关系发生了什么变化",[10,1657,1658],{},[22,1659,1660],{},"伏笔记录",[301,1662,1663],{},[36,1664,1665],{},"埋下的伏笔及其状态（待回收 \u002F 已回收）、涉及的章号",[10,1667,1668,1671],{},[22,1669,1670],{},"时间线","（关键事件及其时间距离）",[301,1673,1674],{},[36,1675,1676],{},"\"第五章后的第三天，X 事件发生\"",[10,1678,1679,1682],{},[22,1680,1681],{},"世界新设定","（剧情中确立、原设定没有的）",[301,1684,1685],{},[36,1686,1687],{},"\"这个世界原本不知道 X，但第十二章中 Y 揭露了 X\"",[10,1689,1690],{},"这一层用向量检索的原因是数据量大（几十万字的小说可能有几百条事实）且不需要完美精确。模型在知道\"五章前主角失去了左腿\"和\"十章前主角加入了某组织\"的基础上，能够合理推理出后续事件。即使系统漏掉了某条非关键事实，模型也不太会产生硬冲突。",[10,1692,1693],{},"时间过滤很重要。检索结果应该默认优先最近的事件，因为离当前章节越近的事件通常影响力越大。\"三章前角色的转折\"比\"五十章前的背景\"更应该被注入。",[14,1695,1697],{"id":1696},"文本层只取最近段落","文本层：只取最近段落",[10,1699,1700],{},"文本层就是原始的正文片段，用于衔接语气和细节。一个几十万字的小说，全部原文根本放不进上下文。",[10,1702,1703],{},"解决方案是简单的：只注入前一章的结尾（约 800 字），这足以让模型维持住文笔连贯性和情感线的延续。对于需要回顾很久之前的情节细节的场景，用事实层的摘要替代——\"第五章中，X 因为 Y 而死亡\"这一条摘要比翻出整个第五章的原文高效得多。",[10,1705,1706],{},"当然也存在\"某章需要直接引用或高度呼应某个很久以前的场景细节\"的情况。这时候文本层可以临时扩大范围，但这是特例，不是常态。",[14,1708,1709],{"id":1709},"具体的实现策略",[10,1711,1712],{},"设定层在每次生成章节前直接注入——要么全部、要么按这章涉及的范围选择。不需要任何检索逻辑，就是结构化的数据块。",[10,1714,1715,1716,1719],{},"事实层维护一个 ",[43,1717,1718],{},"memory.json"," 文件，存储：",[301,1721,1722,1728,1734,1740,1746,1752],{},[36,1723,1724,1727],{},[43,1725,1726],{},"synopsis","：全书概要，每章更新后重写，封顶 1000 字",[36,1729,1730,1733],{},[43,1731,1732],{},"chapterDigests","：逐章一句话摘要",[36,1735,1736,1739],{},[43,1737,1738],{},"characters","：角色的动态状态（与静态档案分开）",[36,1741,1742,1745],{},[43,1743,1744],{},"foreshadow","：伏笔列表，标记待回收或已回收",[36,1747,1748,1751],{},[43,1749,1750],{},"timeline","：关键事件及章号和相对时间",[36,1753,1754,1757],{},[43,1755,1756],{},"worldFacts","：剧情中新确立的设定事实",[10,1759,1760,1761,1764,1765,1768],{},"每章生成完成后，系统调用一次 AI，输入\"旧记忆 + 本章正文\"，要求输出",[22,1762,1763],{},"增量","——这一章新增了什么伏笔、角色状态如何变化、有没有新设定。然后用纯函数 ",[43,1766,1767],{},"normalizeMemory()"," 合并增量到旧记忆里：新伏笔追加、已回收伏笔置状态、角色按 name upsert（同 ID 的覆盖）、synopsis 重写、timeline 追加。",[10,1770,1771,1772,1775,1776,1779],{},"生成下一章时，",[43,1773,1774],{},"buildChapterMessages()"," 的流程变成：注入设定块 → 注入记忆块（由 ",[43,1777,1778],{},"buildMemoryContext()"," 生成） → 注入上一章结尾 → 生成新章。记忆块里包含：最近 6 章摘要、全部角色现状、最后 8 条未回收伏笔、最后 5 条时间线、最后 10 条世界新设定。这些都是硬上限，防止记忆块因为章数增多而无限膨胀。",[14,1781,1782],{"id":1782},"一致性与失败处理",[10,1784,1785],{},"这套流水线的核心难点不是单点生成质量，而是一致性和失败恢复。",[10,1787,1788,1791,1792,1794],{},[22,1789,1790],{},"一致性来自增量而不是合并","。每章记忆更新要求 AI 只输出增量，而不是整个记忆的重写。这样设计有两个好处：一是防止 AI 每次都改掉前面的内容（一种无意义的膨胀），二是增量小得多，解析 JSON 时出错的概率降低。合并逻辑交给纯函数 ",[43,1793,1767],{},"，这个函数可以单测，保证逻辑稳定。",[10,1796,1797,1800,1801,1803],{},[22,1798,1799],{},"同一章重建幂等","。由于 ",[43,1802,1732],{}," 按章号存储，相同章号的摘要会覆盖而不是追加，所以即使记忆重建时对同一章调用多次，也不会出现重复记录。角色状态的 upsert 机制也是同理。",[10,1805,1806,1809,1810,1812,1813,1816],{},[22,1807,1808],{},"记忆更新失败不影响正文","。每章正文先落盘、记忆更新在后。如果记忆更新失败（比如 AI 返回格式错误、JSON 解析异常），降级处理：用这章的 title 或 summary 作为摘要追加到 ",[43,1811,1732],{},"，",[43,1814,1815],{},"lastChapterNo"," 照常推进，其余记忆保持不变。这样下一章的生成可以继续进行，用户不会察觉到记忆层的故障。",[10,1818,1819,1822,1823,1826,1827,1829],{},[22,1820,1821],{},"重建任务的断点续跑","。存量书如果想补全记忆，后台可以跑 ",[43,1824,1825],{},"runMemoryRebuild()"," 任务，按章序遍历，跳过 ",[43,1828,1815],{}," 以内的章（已纳入过的）。这个任务可停、可继续，防止重复扣费。",[14,1831,1832],{"id":1832},"记忆分层的边界",[10,1834,1835,1836,1839],{},"这个设计对应的问题空间是：",[22,1837,1838],{},"长篇写作中保持内容一致性和连贯性","。它解决的是系统层面的记忆管理，不覆盖人工的内容审校和设定调整。",[10,1841,1842],{},"如果作者在某个时刻决定\"我要改前面某角色的设定\"，那是人为修改设定层、然后手动落盘的过程。系统的记忆不会自动同步这种改动，因为改动涉及主观判断。类似的，如果某章生成出来的内容和记忆产生冲突（比如模型莫名其妙加了个新势力），那也需要人工介入编辑，而不是靠记忆系统自动修正。",[10,1844,1845],{},"记忆系统的职责是：提供足够的上下文约束，让模型生成时减少无意义的冲突；一旦冲突出现，系统快速降级，不中断工作流。",{"title":105,"searchDepth":137,"depth":137,"links":1847},[1848,1849,1850,1851,1852,1853,1854],{"id":1546,"depth":137,"text":1546},{"id":1561,"depth":137,"text":1562},{"id":1629,"depth":137,"text":1630},{"id":1696,"depth":137,"text":1697},{"id":1709,"depth":137,"text":1709},{"id":1782,"depth":137,"text":1782},{"id":1832,"depth":137,"text":1832},"2026-06-25",{},"\u002F2026-06-25",{"title":1535,"description":1540},"2026-06-25-长文写作的记忆分层","长篇到几十万字后，用分层记忆解决剧情断裂和设定崩坏——设定层硬约束、事实层概率检索、文本层按需取用，层级的存储与检索策略完全不同。",[1862,1863,1864,1865,1866,1867],"长篇写作","记忆管理","内容生成","AI辅助创作","向量检索","一致性维护","IK_-YIn1bPi-9X-l1KlQn3a-D0cTe45drq3MhS7m-AA",1785406912031]