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