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