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