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