[{"data":1,"prerenderedAt":2763},["ShallowReactive",2],{"\u002F2026-06-29":3,"\u002F2026-06-29-rel":343},{"id":4,"title":5,"body":6,"column":326,"date":327,"description":12,"extension":328,"hero_image":329,"meta":330,"navigation":331,"path":332,"seo":333,"series_id":329,"severity":329,"stem":334,"summary":335,"tags":336,"__hash__":342},"posts\u002F2026-06-29-记忆星系长期记忆可视化工作台.md","模型记错了，用户得能删掉",{"type":7,"value":8,"toc":314},"minimark",[9,13,16,21,29,32,35,38,41,48,51,54,88,91,94,167,174,182,185,188,232,235,238,242,245,248,251,254,257,260,263,266,269,276,283,286,289,296,299,302,305,308,311],[10,11,12],"p",{},"长期记忆存起来容易，用户看不见也管不了。记忆一旦不可见，就会累积错误信息并持续污染后续对话——模型出错时没有纠正入口，错误就永久留存。",[10,14,15],{},"我在 yun-claude 的记忆设计中遇到的问题正是这个。聊天系统能自动从对话抽取持久要点并入库，但页面还是传统的卡片列表，看不出记忆的类型、重要性、使用频次。更严重的是，用户无法编辑或删除那些被错误标记的记忆。所以这次升级的核心不是加一个炫彩的可视化，而是把记忆管理变成一个真正可用的信息工作台。",[17,18,20],"h2",{"id":19},"表格优先而不是星河优先","表格优先，而不是星河优先",[10,22,23,24,28],{},"设计的第一个决策是：",[25,26,27],"strong",{},"主界面用表格承载记忆列表，不用星河画布作主体交互","。",[10,30,31],{},"这听起来反直觉。当初考虑过让星河画布成为核心——节点按重要性大小分布、按创建时间环形排列、搜索命中时高亮。视觉上是漂亮的，也更有\"知识宇宙沉淀\"的产品感。但约束改变了这个选择：",[10,33,34],{},"第一，信息密度。表格一屏可以显示 10-20 条记忆的核心属性（标题、类型、重要性、标签、创建时间），并支持排序和筛选。星河画布要展示相同的信息量，就必须让节点变小、缩放调整、甚至分屏展示——交互成本陡升。而用户——AI agent 的主人——需要快速浏览和定位记忆，不是浏览艺术装置。",[10,36,37],{},"第二，编辑成本。星河上的节点编辑通常要额外打开面板或弹窗。如果记忆管理的主要工作是\"检查是否有错记、删除重复、调整分类\"，那星河就不是最优方案。对比之下，表格 + 右侧详情面板的结构让用户可以同时看到列表和正在编辑的项目，没有上下文切换。",[10,39,40],{},"第三，用户规模。当前 yun-claude 没有真实用户，推断单个用户的记忆条数会比较少（几十到几百）。在这个规模下，表格就足够快了，不必依赖图形加速或向量索引的复杂优化。如果将来规模增大再考虑换方案。",[10,42,43,44,47],{},"所以最终设计是：",[25,45,46],{},"表格为主工作台，右侧详情面板负责编辑与整理，星系可视化只作为辅助视图（后续可选）","。这不是缺乏视觉想象力，而是对工作流的务实权衡。代价是产品感稍弱，但可用性强得多。",[17,49,50],{"id":50},"五类语义记忆与设计令牌",[10,52,53],{},"为了让记忆可见可管，将长期记忆分成五类：",[55,56,57,64,70,76,82],"ul",{},[58,59,60,63],"li",{},[25,61,62],{},"核心记忆","（CORE）：与用户身份、核心项目、重要约束直接相关。模型在每轮对话发送前都应该检索。",[58,65,66,69],{},[25,67,68],{},"常驻记忆","（PERMANENT）：用户的工作背景、技术栈偏好、团队结构等长期背景。",[58,71,72,75],{},[25,73,74],{},"临时记忆","（TEMPORARY）：短期的任务进度、当前问题、临时约束。生命周期短。",[58,77,78,81],{},[25,79,80],{},"知识星云","（KNOWLEDGE）：用户分享的文档要点、API 文档摘录、最佳实践。主要用于 RAG 增强。",[58,83,84,87],{},[25,85,86],{},"其他","（OTHER）：模型无法明确分类或用户手动标记的记忆。",[10,89,90],{},"每一类的关键差异是生命周期和召回策略。核心记忆应该高频被注入 prompt，临时记忆应该自动清理，知识类应该被 RAG 系统共同使用。",[10,92,93],{},"为了在视觉上强化这个分类，为每类配置了一个独立的语义色：",[95,96,97,113],"table",{},[98,99,100],"thead",{},[101,102,103,107,110],"tr",{},[104,105,106],"th",{},"类型",[104,108,109],{},"颜色",[104,111,112],{},"用途",[114,115,116,128,138,148,158],"tbody",{},[101,117,118,122,125],{},[119,120,121],"td",{},"核心",[119,123,124],{},"Amber-500",[119,126,127],{},"节点、筛选按钮、卡片左边线",[101,129,130,133,136],{},[119,131,132],{},"常驻",[119,134,135],{},"Sky-500",[119,137,127],{},[101,139,140,143,146],{},[119,141,142],{},"临时",[119,144,145],{},"Teal-500",[119,147,127],{},[101,149,150,153,156],{},[119,151,152],{},"知识",[119,154,155],{},"Violet-500",[119,157,127],{},[101,159,160,162,165],{},[119,161,86],{},[119,163,164],{},"Slate-500",[119,166,127],{},[10,168,169,170,173],{},"关键的设计决策是：",[25,171,172],{},"颜色不只是装饰，而是结构的一部分","。在表格、筛选条、详情面板的边线上，用户都能看到同一个颜色，强化类型认知。这样的一致性是可信的信息工作台的标志——用户一眼知道哪条记忆是核心、哪条是临时。",[10,175,176,177,181],{},"颜色值不是散落在各个组件里的魔法数字，而是集中在一个 ",[178,179,180],"code",{},"memoryStyles.ts"," 文件中管理。任何需要记忆类型颜色的地方都从这里引用。这样改一个颜色时不必跨多个文件搜索替换，也不会出现同类型在不同地方显示不同色的尴尬局面。",[17,183,184],{"id":184},"记忆模型与后端约束",[10,186,187],{},"后端为每条记忆增加了结构化字段：",[55,189,190,196,202,208,214,220,226],{},[58,191,192,195],{},[178,193,194],{},"title","：节点或列表行的标题，从正文前 18 个字符生成",[58,197,198,201],{},[178,199,200],{},"type","：五类之一",[58,203,204,207],{},[178,205,206],{},"importance","：1-100 的重要性分值，决定节点大小和列表排序",[58,209,210,213],{},[178,211,212],{},"tags","：字符串数组，最多 8 个标签",[58,215,216,219],{},[178,217,218],{},"lastUsedAt","：最近被召回的时间",[58,221,222,225],{},[178,223,224],{},"usedCount","：被召回次数",[58,227,228,231],{},[178,229,230],{},"metadata","：扩展字段，保存来源会话、模型、抽取动作等",[10,233,234],{},"当模型抽取记忆时，输出包含这些结构化字段。服务端必须做兜底和校验：type 非法时改为 OTHER、importance 超范围时裁剪、tags 去重去空白最多保留 8 个。如果抽取失败，仍然保存正文并用默认字段（importance=50, type=OTHER）。",[10,236,237],{},"这些默认值的设计是为了降级优雅。即便结构化抽取失败，记忆也不会丢失，只是分类不精准——这是可以接受的，因为用户随后可以手动调整。",[17,239,241],{"id":240},"搜索筛选与编辑","搜索、筛选与编辑",[10,243,244],{},"用户在表格上方有一个搜索框和类型筛选条。搜索时调用后端的语义搜索接口，命中的记忆在表格中高亮，并自动选中最高相关性的一条，打开右侧详情面板。语义搜索基于向量数据库的余弦相似度匹配——记忆文本被嵌入为 4096 维向量，查询时也转换为向量并与库内存储按相似度排序召回，只返回分值达到阈值（≥0.3）的结果。这样的匹配比关键词搜索精准度高，也支持语义近似的记忆关联（比如\"TypeScript 后端\"和\"TS 服务端\"会被认为相关）。搜索失败时保留当前星河状态并 toast 提示，不中断工作流。",[10,246,247],{},"筛选可以按五类过滤，清除筛选则回到全量视图。如果当前选中的记忆被筛选隐藏了，系统会自动选中可见记忆中最高重要性的那一条；如果没有可见记忆，则关闭详情面板显示空状态。",[10,249,250],{},"详情面板里，用户可以编辑标题、正文、类型、标签和重要性。编辑表单使用产品内设计，不弹浏览器原生弹窗。保存后立即更新列表和节点（如果有星河视图的话）。这样用户修改一条记忆时，不必刷新页面或等待后台同步，改动立刻可见。",[10,252,253],{},"删除是另一个关键能力。必须让用户能删除被错误标记的记忆，否则错误就成了永久污染源。删除按钮放在详情面板的危险操作区，点击后需确认。删除成功后，该条记忆从表格和星河中消失，列表自动选中下一条（如果有的话）。",[17,255,256],{"id":256},"星河与移动端降级",[10,258,259],{},"虽然主界面是表格，但在设计中保留了星河作为可视化补充。桌面端在表格下方或侧边可以显示一个小星图，让用户看到记忆的空间分布——核心记忆聚在中心，临时记忆分散在外围。星河上的节点与表格关联：点击表格行时，星河也高亮对应节点；点击星河节点时，表格定位到对应行。",[10,261,262],{},"但星河不是强制项。第一版的实现可能不包含完整星河，而是先确保表格工作台完整可用。星河可以作为后续的增强——用户如果觉得需要可视化辅助，才加上去。",[10,264,265],{},"移动端设计上，星河更是不现实（屏幕太小）。所以移动端完全降级为列表视图，点击列表项后通过底部抽屉显示详情和编辑表单。搜索和类型 tabs 放在顶部，保持核心交互可用。这样既避免了表格在手机上的横向溢出，也保证了可用性。",[17,267,268],{"id":268},"节点布局与稳定性",[10,270,271,272,275],{},"如果星河要实现，一个重要的设计细节是：",[25,273,274],{},"节点位置必须稳定","。即便刷新页面或重新打开应用，同一批记忆应该保持相同的位置，这样用户才能形成空间记忆——\"核心记忆总在中心，临时的在右上角\"。",[10,277,278,279,282],{},"这意味着节点位置不能是随机的实时布局，也不能用物理模拟（那会每次都算不同的位置）。用 ",[178,280,281],{},"createdAt"," 字段参与布局计算，保证确定性：同一条记忆的创建时间固定了，它在环形排列中的角度也就固定了。结合 importance 决定距离中心的远近，位置就完全由数据驱动，刷新后毫厘不差。",[17,284,285],{"id":285},"一致性与可维护性",[10,287,288],{},"这个设计的关键约束是一致性。如果记忆在表格中是 Amber-500（核心），那在星河、筛选、详情面板的边线上也必须是 Amber-500。任何拆散这个一致性的修改都会破坏用户的心智模型。",[10,290,291,292,295],{},"所以在设计系统里明确了这一点：",[25,293,294],{},"新增或调整记忆类型视觉时，先更新设计文档里的语义色板和 memoryStyles.ts，再在各组件中引用，不允许组件各自复制色值","。这样的约束看似严苛，但它保证了长期的可维护性——下次要改颜色时，只需改一个文件。",[17,297,298],{"id":298},"代价与权衡",[10,300,301],{},"这个设计的代价是什么？",[10,303,304],{},"第一，视觉冲击力比不上星河优先。表格是务实的设计，不够\"黑科技\"感。如果产品定位是\"AI 记忆系统\"要卖视觉冲击，这个方案会显得保守。",[10,306,307],{},"第二，星河的潜力没有完全释放。放弃了复杂的关系推理、自由漫游、力导向布局这些\"高级\"可视化特性，原因就是表格工作台不需要它们，而加上去反而添加复杂度。",[10,309,310],{},"第三，设计系统的维护成本提高了。一致性的要求意味着每次改动都要考虑全局影响。但这其实是长期收益——减少了 bug 和不一致的可能。",[10,312,313],{},"这些代价对 yun-claude 是可以接受的，因为当前用户还不多，重点是让产品可用，而不是炫技。如果将来用户规模上升，数百条甚至千条记忆的管理场景出现，表格 + 星河的混合界面可能不够，那时再考虑更激进的可视化方案。现在，信息工作台的优先级明确高于视觉体验。",{"title":315,"searchDepth":316,"depth":316,"links":317},"",2,[318,319,320,321,322,323,324,325],{"id":19,"depth":316,"text":20},{"id":50,"depth":316,"text":50},{"id":184,"depth":316,"text":184},{"id":240,"depth":316,"text":241},{"id":256,"depth":316,"text":256},{"id":268,"depth":316,"text":268},{"id":285,"depth":316,"text":285},{"id":298,"depth":316,"text":298},"Agent 平台","2026-06-29","md",null,{},true,"\u002F2026-06-29",{"title":5,"description":12},"2026-06-29-记忆星系长期记忆可视化工作台","表格优先的记忆管理：用高信息密度工作台承载持久要点，可视化只作辅助，设计令牌贯穿全局。",[337,338,339,340,341],"长期记忆","信息工作台","设计决策","语义搜索","设计令牌","gsJKxlNQ-FiADsw2ca6e5WJo-_uyYDFZ55iuiMr4jQE",[344,1061,1709],{"id":345,"title":346,"body":347,"column":326,"date":1046,"description":351,"extension":328,"hero_image":329,"meta":1047,"navigation":331,"path":1048,"seo":1049,"series_id":329,"severity":329,"stem":1050,"summary":1051,"tags":1052,"__hash__":1060},"posts\u002F2026-08-31-阈值不能推只能量.md","阈值不能推，只能量",{"type":7,"value":348,"toc":1037},[349,352,355,359,362,368,371,374,383,386,389,394,397,412,439,445,448,452,463,466,472,475,478,481,486,489,492,553,556,559,622,625,630,634,637,643,646,649,653,656,662,665,671,674,677,684,690,693,696,702,705,748,751,755,758,761,767,770,773,777,783,845,848,946,949,952,1000,1003,1007,1010,1020,1023,1030,1033],[10,350,351],{},"知识库的检索参数有三组：分块阈值、召回阈值、精排阈值。这一天把三组都动了一遍，每一组都留下了实测数据。",[10,353,354],{},"起因是一个看不太出来的现象：知识库好像没被用上。",[17,356,358],{"id":357},"一分块中位数-212-字","一、分块：中位数 212 字",[10,360,361],{},"先量现状。生产库里的分块统计：",[363,364,365],"blockquote",{},[10,366,367],{},"13244 个分块中位数只有 212 字，而目标 2400 字，62% 的块不足 300 字。",[10,369,370],{},"目标块大小是 2400 字，实际交付的是 212。差了十倍。",[10,372,373],{},"分块器原来的逻辑是「一个标题一个块」。这在正常文档上没问题，但清单型、模板型文档里，几乎每一行列表项都会被标题识别逻辑认成标题：",[375,376,381],"pre",{"className":377,"code":379,"language":380},[378],"language-text","1. 原本想达成什么？\n","text",[178,382,379],{"__ignoreMap":315},[10,384,385],{},"这一行被当成标题，于是自己成了一个块。一篇文档被切成几十个几十字的碎片，注入给模型的全是碎片。",[10,387,388],{},"更严重的情况在连续列表项之间没有正文时。老实现让标题行只活在「面包屑」元数据里，不写进块正文。于是被误判成标题的那一行文字直接消失：",[363,390,391],{},[10,392,393],{},"标题行只活在面包屑里，整行文字直接丢失（那类文档的四个核心问题在索引里根本不存在）。",[10,395,396],{},"两处改动：",[375,398,402],{"className":399,"code":400,"language":401,"meta":315,"style":315},"language-ts shiki shiki-themes github-light github-dark","\u002F\u002F 攒够了才在这里切：标题是「首选切点」，不是「强制切点」。\n","ts",[178,403,404],{"__ignoreMap":315},[405,406,409],"span",{"class":407,"line":408},"line",1,[405,410,400],{"class":411},"sJ8bj",[375,413,415],{"className":399,"code":414,"language":401,"meta":315,"style":315},"\u002F\u002F 不变量：每一行输入都要落进某个块的正文，面包屑只是附加元数据。\n\u002F\u002F 老实现让标题行只活在面包屑里，于是被 detectHeading 误判成标题的列表项\n\u002F\u002F （「1. 原本想达成什么？」这类）整行文字就没了——连续几个列表项时只留得住最后一条。\n\u002F\u002F 与面包屑重复一次可以接受，丢字不行。\n",[178,416,417,422,427,433],{"__ignoreMap":315},[405,418,419],{"class":407,"line":408},[405,420,421],{"class":411},"\u002F\u002F 不变量：每一行输入都要落进某个块的正文，面包屑只是附加元数据。\n",[405,423,424],{"class":407,"line":316},[405,425,426],{"class":411},"\u002F\u002F 老实现让标题行只活在面包屑里，于是被 detectHeading 误判成标题的列表项\n",[405,428,430],{"class":407,"line":429},3,[405,431,432],{"class":411},"\u002F\u002F （「1. 原本想达成什么？」这类）整行文字就没了——连续几个列表项时只留得住最后一条。\n",[405,434,436],{"class":407,"line":435},4,[405,437,438],{"class":411},"\u002F\u002F 与面包屑重复一次可以接受，丢字不行。\n",[10,440,441,442,28],{},"标题从「强制切点」降为「首选切点」：缓冲区不足阈值时，标题并入当前块，不切。同时标题行本身写进正文，成为一条不变量——",[25,443,444],{},"每一行输入都要落进某个块的正文",[10,446,447],{},"实测效果：一篇文档从 3 块 52\u002F67\u002F100 字（四个核心问题全丢）变成 1 块 235 字，内容完整。",[17,449,451],{"id":450},"二按比例推算推出了全场最差点","二、按比例推算，推出了全场最差点",[10,453,454,455,458,459,462],{},"第一版把阈值定成 ",[178,456,457],{},"maxChars × 0.6","。生产 ",[178,460,461],{},"maxChars"," 是 2400，算出来是 1440。",[10,464,465],{},"结果整篇文档并成一个块。上线后实测检索：",[375,467,470],{"className":468,"code":469,"language":380},[378],"5 篇文档 6 个查询，每篇取最佳命中分再平均\n  阈值 0（纯按小节切） 0.7046\n  阈值 250            0.6750\n  阈值 1440（线上）    0.4978\n",[178,471,469],{"__ignoreMap":315},[10,473,474],{},"1440 正好是最差的那个。",[10,476,477],{},"同一篇文档对「核心四问」这个查询，切成小节时得分 0.77，并成整块时只有 0.33——在全库 113 块里排到第 109 名。内容修好了，却再也检索不到。",[10,479,480],{},"根因在生产用的向量模型上：",[363,482,483],{},[10,484,485],{},"生产 embedding 模型对「主题聚焦的小段」打分远高于「整篇文档」。",[10,487,488],{},"原来那个「一个标题一个块」的设计，主题纯度是对的。推翻它是错的判断。真正的缺陷只有一条——标题行被丢弃。",[10,490,491],{},"最终取值 250：",[375,493,495],{"className":399,"code":494,"language":401,"meta":315,"style":315},"\u002F**\n * 缺省小节合并阈值。250 是在生产 embedding 模型上实测标定的，不是拍脑袋：\n * 5 篇文档 6 个查询，取每篇的最佳命中分做平均——\n *   阈值 0（纯按小节切）0.7046 \u002F 250 → 0.6750 \u002F 1440（= maxChars×0.6）→ 0.4978\n * 这个模型对「主题聚焦的小段」打分远高于「整篇文档」……\n * 所以阈值必须小，千万别再按 maxChars 的比例去推——那样在生产的 2400 上会算出 1440，\n * 正好是最差点。\n * 取 250 而不是 0：排序只差 4%，但块从几十字变成 250~330 字，\n * 同样召回 6 段能多喂两三倍的正文。\n *\u002F\n",[178,496,497,502,507,512,517,523,529,535,541,547],{"__ignoreMap":315},[405,498,499],{"class":407,"line":408},[405,500,501],{"class":411},"\u002F**\n",[405,503,504],{"class":407,"line":316},[405,505,506],{"class":411}," * 缺省小节合并阈值。250 是在生产 embedding 模型上实测标定的，不是拍脑袋：\n",[405,508,509],{"class":407,"line":429},[405,510,511],{"class":411}," * 5 篇文档 6 个查询，取每篇的最佳命中分做平均——\n",[405,513,514],{"class":407,"line":435},[405,515,516],{"class":411}," *   阈值 0（纯按小节切）0.7046 \u002F 250 → 0.6750 \u002F 1440（= maxChars×0.6）→ 0.4978\n",[405,518,520],{"class":407,"line":519},5,[405,521,522],{"class":411}," * 这个模型对「主题聚焦的小段」打分远高于「整篇文档」……\n",[405,524,526],{"class":407,"line":525},6,[405,527,528],{"class":411}," * 所以阈值必须小，千万别再按 maxChars 的比例去推——那样在生产的 2400 上会算出 1440，\n",[405,530,532],{"class":407,"line":531},7,[405,533,534],{"class":411}," * 正好是最差点。\n",[405,536,538],{"class":407,"line":537},8,[405,539,540],{"class":411}," * 取 250 而不是 0：排序只差 4%，但块从几十字变成 250~330 字，\n",[405,542,544],{"class":407,"line":543},9,[405,545,546],{"class":411}," * 同样召回 6 段能多喂两三倍的正文。\n",[405,548,550],{"class":407,"line":549},10,[405,551,552],{"class":411}," *\u002F\n",[10,554,555],{},"取 250 而不是 0 的理由是这段话里第二重要的部分：排序只差 4%，但每个块从几十字变成两三百字，同样召回 6 段能多喂几倍的正文。",[10,557,558],{},"代码里还留了一条兜底：",[375,560,562],{"className":399,"code":561,"language":401,"meta":315,"style":315},"\u002F\u002F 取 min：maxChars 很小的配置（测试里 300）不能让阈值反超块大小本身\nconst minChunkChars =\n  opts.minChunkChars ?? Math.min(DEFAULT_MIN_CHUNK_CHARS, Math.floor(maxChars * 0.6));\n",[178,563,564,569,582],{"__ignoreMap":315},[405,565,566],{"class":407,"line":408},[405,567,568],{"class":411},"\u002F\u002F 取 min：maxChars 很小的配置（测试里 300）不能让阈值反超块大小本身\n",[405,570,571,575,579],{"class":407,"line":316},[405,572,574],{"class":573},"szBVR","const",[405,576,578],{"class":577},"sj4cs"," minChunkChars",[405,580,581],{"class":573}," =\n",[405,583,584,588,591,594,598,601,604,607,610,613,616,619],{"class":407,"line":429},[405,585,587],{"class":586},"sVt8B","  opts.minChunkChars ",[405,589,590],{"class":573},"??",[405,592,593],{"class":586}," Math.",[405,595,597],{"class":596},"sScJk","min",[405,599,600],{"class":586},"(",[405,602,603],{"class":577},"DEFAULT_MIN_CHUNK_CHARS",[405,605,606],{"class":586},", Math.",[405,608,609],{"class":596},"floor",[405,611,612],{"class":586},"(maxChars ",[405,614,615],{"class":573},"*",[405,617,618],{"class":577}," 0.6",[405,620,621],{"class":586},"));\n",[10,623,624],{},"以及一条守卫测试，专门防「有人再推一遍公式」：",[363,626,627],{},[10,628,629],{},"加一条守卫测试钉死生产口径（阈值退回按比例推算即变红），防止将来有人再推一遍公式又回到 1440。",[17,631,633],{"id":632},"三召回阈值会把整轮清零","三、召回阈值：会把整轮清零",[10,635,636],{},"分块改大之后，绝对余弦分整体下移。而召回阈值还是旧值 0.5：",[375,638,641],{"className":639,"code":640,"language":380},[378],"正确命中落在 0.43~0.63，旧值会把最高分 0.488 的查询整轮清零——\n检索到了正确文档却被门槛全部丢弃，用户看到的就是「知识库没被使用」。\n",[178,642,640],{"__ignoreMap":315},[10,644,645],{},"这条解释了我一开始看到的那个现象。检索其实命中了，只是分数没过门槛，于是整轮被丢掉，模型什么也没拿到。",[10,647,648],{},"改成 0.35。",[17,650,652],{"id":651},"四精排从-912-到-1212","四、精排：从 9\u002F12 到 12\u002F12",[10,654,655],{},"同一天启用了 cross-encoder 精排。A\u002FB 实测：",[375,657,660],{"className":658,"code":659,"language":380},[378],"12 条查询 A\u002FB 实测：\n  纯向量  命中 9\u002F12，整轮零注入 1 次\n  开精排  命中 12\u002F12，整轮零注入 0 次，目标文档 10 条排第 1\n",[178,661,659],{"__ignoreMap":315},[10,663,664],{},"修好的都是「换了说法」的查询——向量检索的固有短板。举一个例子：",[375,666,669],{"className":667,"code":668,"language":380},[378],"「我想要复刻爆款视频…使用画布」注入 0 段 → 4 段\n（相关文档被向量埋在后面，精排提到第 2 名）\n",[178,670,668],{"__ignoreMap":315},[10,672,673],{},"精排启用时把两个阈值也一起改了。这是当天最曲折的一处。",[10,675,676],{},"第一版把精排阈值设成 0.15。发版后跑完整测试，出现随机漏召。",[10,678,679,680,683],{},"原因是精排的",[25,681,682],{},"绝对分不稳定","：",[375,685,688],{"className":686,"code":687,"language":380},[378],"同一查询同一候选集，「金字塔原理怎么做到结论先行」两次实测 0.251 与 0.135——\n排名都稳定第 1，只是分数漂了近一半。0.15 卡在中间，于是第二次整轮零注入。\n",[178,689,687],{"__ignoreMap":315},[10,691,692],{},"排名是稳定的，分数会漂。所以不能用绝对分当门槛去「把关质量」。",[10,694,695],{},"阈值扫描：",[375,697,700],{"className":698,"code":699,"language":380},[378],"0.02~0.10 均 12\u002F12 零丢弃，0.15\u002F0.20 → 11\u002F12 丢 1 次\n",[178,701,699],{"__ignoreMap":315},[10,703,704],{},"最终取 0.05，并把职责写清楚：",[375,706,708],{"className":399,"code":707,"language":401,"meta":315,"style":315},"\u002F**\n * 精排阈值。**它的职责只是扔掉垃圾，不是把关质量**——质量由排序保证：\n * 12 条查询里精排把正确目标全部放进了前 3 名（10 条第 1）。\n *\n * 取 0.05 而不是更高，是因为**精排的绝对分不稳定**……\n * 观测到的正确目标最低分 0.131，取 0.05 留约 60% 余量；\n * 垃圾档在 0.014~0.025，仍被干净滤掉。\n *\u002F\n",[178,709,710,714,719,724,729,734,739,744],{"__ignoreMap":315},[405,711,712],{"class":407,"line":408},[405,713,501],{"class":411},[405,715,716],{"class":407,"line":316},[405,717,718],{"class":411}," * 精排阈值。**它的职责只是扔掉垃圾，不是把关质量**——质量由排序保证：\n",[405,720,721],{"class":407,"line":429},[405,722,723],{"class":411}," * 12 条查询里精排把正确目标全部放进了前 3 名（10 条第 1）。\n",[405,725,726],{"class":407,"line":435},[405,727,728],{"class":411}," *\n",[405,730,731],{"class":407,"line":519},[405,732,733],{"class":411}," * 取 0.05 而不是更高，是因为**精排的绝对分不稳定**……\n",[405,735,736],{"class":407,"line":525},[405,737,738],{"class":411}," * 观测到的正确目标最低分 0.131，取 0.05 留约 60% 余量；\n",[405,740,741],{"class":407,"line":531},[405,742,743],{"class":411}," * 垃圾档在 0.014~0.025，仍被干净滤掉。\n",[405,745,746],{"class":407,"line":537},[405,747,552],{"class":411},[10,749,750],{},"观测到的垃圾档在 0.014~0.025，正确目标最低 0.131。0.05 落在两者之间，两边都有余量。",[17,752,754],{"id":753},"五超时静默降级最贵","五、超时：静默降级最贵",[10,756,757],{},"同一批里还修了超时。",[10,759,760],{},"精排失败会自动降级回向量序，不阻断聊天。这个设计是对的——但有代价：",[375,762,765],{"className":763,"code":764,"language":380},[378],"法律知识库 30 块共 4.5 万字，冷调用实测 2263ms，只剩 737ms 余量。\n完整测试里有一条查询当场超时降级。\n超时是静默的，日志不报错，用户侧表现为「有时准有时不准」，极难归因。\n",[178,766,764],{"__ignoreMap":315},[10,768,769],{},"「有时准有时不准」这句话在这一天的上下文里已经出现第二次了——早上是参考图，这里是检索。",[10,771,772],{},"超时从 3000 毫秒提到 8000，给冷调用 3.5 倍余量。真卡死仍有降级兜底。",[17,774,776],{"id":775},"六三个阈值放在一起","六、三个阈值放在一起",[10,778,779,780],{},"三条修正的形式不同，结论是同一条：",[25,781,782],{},"阈值不能推导，只能测量。",[95,784,785,801],{},[98,786,787],{},[101,788,789,792,795,798],{},[104,790,791],{},"参数",[104,793,794],{},"推导值",[104,796,797],{},"实测值",[104,799,800],{},"推导错在哪",[114,802,803,817,831],{},[101,804,805,808,811,814],{},[119,806,807],{},"分块合并阈值",[119,809,810],{},"1440（按比例 0.6）",[119,812,813],{},"250",[119,815,816],{},"假设「块越大越好」，而该模型偏好主题聚焦的小段",[101,818,819,822,825,828],{},[119,820,821],{},"召回阈值",[119,823,824],{},"0.5（惯例值）",[119,826,827],{},"0.35",[119,829,830],{},"分块改动后分数分布整体下移，旧门槛把命中全丢",[101,832,833,836,839,842],{},[119,834,835],{},"精排阈值",[119,837,838],{},"0.15（留余量）",[119,840,841],{},"0.05",[119,843,844],{},"绝对分本身会漂近一半，拿漂移量当门槛必然随机漏召",[10,846,847],{},"三个值都有实测数字支撑，也都配了守卫测试。其中两条测试写的是「不许回到某个值」，而不是「必须等于某值」：",[375,849,851],{"className":399,"code":850,"language":401,"meta":315,"style":315},"it(\"向量阈值不得回到会整轮清零的 0.5\", () => {\n  expect(DEFAULT_KB_MIN_SCORE).toBeLessThanOrEqual(0.4);\n});\n\nit(\"精排阈值不得高到会随分数漂移漏召——观测最低正确分 0.131\", () => {\n  expect(DEFAULT_KB_RERANK_MIN_SCORE).toBeLessThanOrEqual(0.1);\n});\n",[178,852,853,873,897,902,907,922,942],{"__ignoreMap":315},[405,854,855,858,860,864,867,870],{"class":407,"line":408},[405,856,857],{"class":596},"it",[405,859,600],{"class":586},[405,861,863],{"class":862},"sZZnC","\"向量阈值不得回到会整轮清零的 0.5\"",[405,865,866],{"class":586},", () ",[405,868,869],{"class":573},"=>",[405,871,872],{"class":586}," {\n",[405,874,875,878,880,883,886,889,891,894],{"class":407,"line":316},[405,876,877],{"class":596},"  expect",[405,879,600],{"class":586},[405,881,882],{"class":577},"DEFAULT_KB_MIN_SCORE",[405,884,885],{"class":586},").",[405,887,888],{"class":596},"toBeLessThanOrEqual",[405,890,600],{"class":586},[405,892,893],{"class":577},"0.4",[405,895,896],{"class":586},");\n",[405,898,899],{"class":407,"line":429},[405,900,901],{"class":586},"});\n",[405,903,904],{"class":407,"line":435},[405,905,906],{"emptyLinePlaceholder":331},"\n",[405,908,909,911,913,916,918,920],{"class":407,"line":519},[405,910,857],{"class":596},[405,912,600],{"class":586},[405,914,915],{"class":862},"\"精排阈值不得高到会随分数漂移漏召——观测最低正确分 0.131\"",[405,917,866],{"class":586},[405,919,869],{"class":573},[405,921,872],{"class":586},[405,923,924,926,928,931,933,935,937,940],{"class":407,"line":525},[405,925,877],{"class":596},[405,927,600],{"class":586},[405,929,930],{"class":577},"DEFAULT_KB_RERANK_MIN_SCORE",[405,932,885],{"class":586},[405,934,888],{"class":596},[405,936,600],{"class":586},[405,938,939],{"class":577},"0.1",[405,941,896],{"class":586},[405,943,944],{"class":407,"line":531},[405,945,901],{"class":586},[10,947,948],{},"这样写是因为真正的风险不是「有人改了数值」，而是「有人又推了一遍公式」。测试防的是后者。",[10,950,951],{},"还有一条测试值得抄下来：",[375,953,955],{"className":399,"code":954,"language":401,"meta":315,"style":315},"it(\"精排阈值必须低于向量阈值——两者不是同一个量纲\", () => {\n  \u002F\u002F 精排是 cross-encoder 分，向量是余弦分，拿同一个数去卡两边必然有一边错\n  expect(DEFAULT_KB_RERANK_MIN_SCORE).toBeLessThan(DEFAULT_KB_MIN_SCORE);\n});\n",[178,956,957,972,977,996],{"__ignoreMap":315},[405,958,959,961,963,966,968,970],{"class":407,"line":408},[405,960,857],{"class":596},[405,962,600],{"class":586},[405,964,965],{"class":862},"\"精排阈值必须低于向量阈值——两者不是同一个量纲\"",[405,967,866],{"class":586},[405,969,869],{"class":573},[405,971,872],{"class":586},[405,973,974],{"class":407,"line":316},[405,975,976],{"class":411},"  \u002F\u002F 精排是 cross-encoder 分，向量是余弦分，拿同一个数去卡两边必然有一边错\n",[405,978,979,981,983,985,987,990,992,994],{"class":407,"line":429},[405,980,877],{"class":596},[405,982,600],{"class":586},[405,984,930],{"class":577},[405,986,885],{"class":586},[405,988,989],{"class":596},"toBeLessThan",[405,991,600],{"class":586},[405,993,882],{"class":577},[405,995,896],{"class":586},[405,997,998],{"class":407,"line":435},[405,999,901],{"class":586},[10,1001,1002],{},"同一个数量级、看起来可比，实际是两个不同的量纲。这种错觉在阈值调参里很常见，能用一条断言拦下来比写在注释里可靠。",[17,1004,1006],{"id":1005},"七评测集的缺口","七、评测集的缺口",[10,1008,1009],{},"这一轮所有数字来自临时扫的查询集：5 篇文档 6 个查询、12 条查询。",[10,1011,1012,1013,1016,1017,28],{},"仓库里确实有一个召回评测脚本（用户问题、期望命中的文档名、recall@K \u002F precision@K \u002F MRR 双模式），但它的标注集还是占位内容——两条用例，",[178,1014,1015],{},"kbIds"," 的值是字面量 ",[178,1018,1019],{},"\u003C替换为真实 kbId>",[10,1021,1022],{},"也就是说，这一轮的标定用的数据集没有入库。数字留在代码注释、配置和环境变量说明里，跑分的脚本和查询集没有留下。",[10,1024,1025,1026,1029],{},"这是这次工作里最该补上的一环。",[25,1027,1028],{},"阈值有实测依据，但依据本身不可复现。"," 下次有人想调整，只能重新扫一遍查询。",[10,1031,1032],{},"补的做法是明确的：把这一轮用的查询集和期望结果补进评测脚本的标注集，让它成为下一次调参的起点。参数变更时先跑评测再改值，改完之后把新的分数写进注释——注释里的数字应该来自一次可复现的运行，而不是一次性的手测。",[1034,1035,1036],"style",{},"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);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}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}",{"title":315,"searchDepth":316,"depth":316,"links":1038},[1039,1040,1041,1042,1043,1044,1045],{"id":357,"depth":316,"text":358},{"id":450,"depth":316,"text":451},{"id":632,"depth":316,"text":633},{"id":651,"depth":316,"text":652},{"id":753,"depth":316,"text":754},{"id":775,"depth":316,"text":776},{"id":1005,"depth":316,"text":1006},"2026-08-31",{},"\u002F2026-08-31",{"title":346,"description":351},"2026-08-31-阈值不能推只能量","知识库检索质量的三次修正：分块阈值按比例推算算出了全场最差点，精排阈值设高了会随分数漂移随机漏召，超时余量不够会静默降级。三次都靠实测数据定值。",[1053,1054,1055,1056,1057,1058,1059],"知识库","RAG","分块","检索","cross-encoder","阈值标定","评测","VbN-ARGFFeAmQMRZf2jG-4SfdtN22uzMLyCrc3dCuA8",{"id":1062,"title":1063,"body":1064,"column":326,"date":1695,"description":1068,"extension":328,"hero_image":329,"meta":1696,"navigation":331,"path":1697,"seo":1698,"series_id":329,"severity":329,"stem":1699,"summary":1700,"tags":1701,"__hash__":1708},"posts\u002F2026-08-20-画布这一步深链运行历史与续跑.md","画布这一步：深链、运行历史与续跑",{"type":7,"value":1065,"toc":1685},[1066,1069,1072,1076,1079,1082,1088,1091,1105,1119,1122,1126,1129,1135,1138,1145,1148,1157,1160,1164,1167,1172,1205,1208,1213,1233,1240,1243,1247,1250,1339,1342,1345,1349,1352,1370,1379,1382,1385,1389,1392,1395,1415,1418,1423,1426,1458,1461,1557,1560,1566,1573,1577,1580,1583,1621,1628,1631,1637,1640,1645,1649,1652,1661,1667,1673,1679,1682],[10,1067,1068],{},"画布是这套平台里最复杂的界面。节点、连线、参数、批量框、运行状态，任何一件事都要在同一个视口里表达清楚。",[10,1070,1071],{},"这一天补齐八项操作。每一项都不难，难的是它们之间不能互相打架。",[17,1073,1075],{"id":1074},"一url-是唯一事实来源","一、URL 是唯一事实来源",[10,1077,1078],{},"原先画布是个单页状态机：打开就是列表，点进去切到编辑器，刷新回列表。",[10,1080,1081],{},"改成路由：",[375,1083,1086],{"className":1084,"code":1085,"language":380},[378],"\u002Fcanvas            列表\n\u002Fcanvas\u002F:id        编辑器\n",[178,1087,1085],{"__ignoreMap":315},[10,1089,1090],{},"四个行为一起对齐：",[55,1092,1093,1096,1099,1102],{},[58,1094,1095],{},"深链直达编辑器。",[58,1097,1098],{},"刷新不回列表。",[58,1100,1101],{},"后退回列表，而不是退出画布模块。",[58,1103,1104],{},"打开画布写入地址栏。",[10,1106,1107,1110,1111,1114,1115,1118],{},[178,1108,1109],{},"popstate"," 监听已有的解析函数，",[178,1112,1113],{},"pushState"," \u002F ",[178,1116,1117],{},"replaceState"," 写入。",[10,1120,1121],{},"这条改动看起来只是加了个路由，实际改变的是状态的归属：画布 ID 从组件内的 ref 变成了 URL 的一部分。后面几项都依赖它——运行历史要能链到具体的运行，批量框要能被分享，都要求「当前在看哪张画布」是一个可以从外部确定的量。",[17,1123,1125],{"id":1124},"二运行历史","二、运行历史",[10,1127,1128],{},"一个新面板，两个接口：",[375,1130,1133],{"className":1131,"code":1132,"language":380},[378],"GET \u002Fapi\u002Fcanvas-flow\u002Fruns?flowId=…      列表\nGET \u002Fapi\u002Fcanvas-flow\u002Fruns\u002F:id           快照\n",[178,1134,1132],{"__ignoreMap":315},[10,1136,1137],{},"列表默认取 10 条。点开某一条，用它的快照推导出画布上每个节点的状态，覆盖显示。",[10,1139,1140,1141,1144],{},"这个面板的数据来源和实时运行状态用的是同一套 ",[178,1142,1143],{},"nodeStates","——历史记录不是另一条平行显示，而是把画布切到那一次运行的视角。",[10,1146,1147],{},"登录态走单一来源：",[375,1149,1151],{"className":399,"code":1150,"language":401,"meta":315,"style":315},"\u002F\u002F 登录态只有 authToken() 一个来源（有守卫测试盯着，别直读 localStorage）\n",[178,1152,1153],{"__ignoreMap":315},[405,1154,1155],{"class":407,"line":408},[405,1156,1150],{"class":411},[10,1158,1159],{},"这条注释是守卫测试抓出来的结果，不是提前的设计。",[17,1161,1163],{"id":1162},"三复制粘贴","三、复制粘贴",[10,1165,1166],{},"复制粘贴里有两个决定值得记。",[10,1168,1169],{},[25,1170,1171],{},"用应用内剪贴板，不碰系统剪贴板。",[375,1173,1175],{"className":399,"code":1174,"language":401,"meta":315,"style":315},"let clipboard: FlowClipboardPayload | null = null;\n",[178,1176,1177],{"__ignoreMap":315},[405,1178,1179,1182,1185,1188,1191,1194,1197,1200,1202],{"class":407,"line":408},[405,1180,1181],{"class":573},"let",[405,1183,1184],{"class":586}," clipboard",[405,1186,1187],{"class":573},":",[405,1189,1190],{"class":596}," FlowClipboardPayload",[405,1192,1193],{"class":573}," |",[405,1195,1196],{"class":577}," null",[405,1198,1199],{"class":573}," =",[405,1201,1196],{"class":577},[405,1203,1204],{"class":586},";\n",[10,1206,1207],{},"粘贴的内容是节点和边，格式带版本号。走系统剪贴板意味着把画布的 JSON 写进用户的剪贴板，用户去别处粘贴会看到一堆结构数据。应用内的模块级变量没有这个问题。",[10,1209,1210],{},[25,1211,1212],{},"没选节点时不拦截。",[375,1214,1216],{"className":399,"code":1215,"language":401,"meta":315,"style":315},"\u002F\u002F Cmd\u002FCtrl+C：复制选中的节点。\n\u002F\u002F 没选节点就不拦截——用户可能正在复制节点产物里的文字，抢了就是坏默认行为\n\u002F\u002F Cmd\u002FCtrl+V：粘贴。应用内剪贴板为空时同样放行系统默认行为\n",[178,1217,1218,1223,1228],{"__ignoreMap":315},[405,1219,1220],{"class":407,"line":408},[405,1221,1222],{"class":411},"\u002F\u002F Cmd\u002FCtrl+C：复制选中的节点。\n",[405,1224,1225],{"class":407,"line":316},[405,1226,1227],{"class":411},"\u002F\u002F 没选节点就不拦截——用户可能正在复制节点产物里的文字，抢了就是坏默认行为\n",[405,1229,1230],{"class":407,"line":429},[405,1231,1232],{"class":411},"\u002F\u002F Cmd\u002FCtrl+V：粘贴。应用内剪贴板为空时同样放行系统默认行为\n",[10,1234,1235,1236,1239],{},"这两条合起来是一个原则：",[25,1237,1238],{},"快捷键只在它有明确意图时生效","。用户按 Cmd+C 时可能是想复制提示词里的文字，此时抢过来是损失。",[10,1241,1242],{},"粘贴到画布时重新生成 ID（节点 ID 最多重试 20 次防碰撞），偏移 24 像素，粘贴的结果进入撤销栈并成为新的选中项。",[17,1244,1246],{"id":1245},"四一键整理布局","四、一键整理布局",[10,1248,1249],{},"按依赖深度分层的纯函数，不引布局引擎：",[375,1251,1253],{"className":399,"code":1252,"language":401,"meta":315,"style":315},"\u002F**\n * 一键整理布局：按依赖深度分层的纯函数。\n *\n * 不引 dagre\u002Felk——画布的图是小规模 DAG（上限 100 节点），\n * 「上游在左、下游在右、同层竖排」这一条规则就够读顺一张乱图，\n * 引一个布局引擎为它的边缘能力买单不值。\n *\u002F\nconst COLUMN_GAP = 380;\nconst ROW_GAP = 240;\nconst ORIGIN = { x: 40, y: 60 };\n",[178,1254,1255,1259,1264,1268,1273,1278,1283,1287,1301,1315],{"__ignoreMap":315},[405,1256,1257],{"class":407,"line":408},[405,1258,501],{"class":411},[405,1260,1261],{"class":407,"line":316},[405,1262,1263],{"class":411}," * 一键整理布局：按依赖深度分层的纯函数。\n",[405,1265,1266],{"class":407,"line":429},[405,1267,728],{"class":411},[405,1269,1270],{"class":407,"line":435},[405,1271,1272],{"class":411}," * 不引 dagre\u002Felk——画布的图是小规模 DAG（上限 100 节点），\n",[405,1274,1275],{"class":407,"line":519},[405,1276,1277],{"class":411}," * 「上游在左、下游在右、同层竖排」这一条规则就够读顺一张乱图，\n",[405,1279,1280],{"class":407,"line":525},[405,1281,1282],{"class":411}," * 引一个布局引擎为它的边缘能力买单不值。\n",[405,1284,1285],{"class":407,"line":531},[405,1286,552],{"class":411},[405,1288,1289,1291,1294,1296,1299],{"class":407,"line":537},[405,1290,574],{"class":573},[405,1292,1293],{"class":577}," COLUMN_GAP",[405,1295,1199],{"class":573},[405,1297,1298],{"class":577}," 380",[405,1300,1204],{"class":586},[405,1302,1303,1305,1308,1310,1313],{"class":407,"line":543},[405,1304,574],{"class":573},[405,1306,1307],{"class":577}," ROW_GAP",[405,1309,1199],{"class":573},[405,1311,1312],{"class":577}," 240",[405,1314,1204],{"class":586},[405,1316,1317,1319,1322,1324,1327,1330,1333,1336],{"class":407,"line":549},[405,1318,574],{"class":573},[405,1320,1321],{"class":577}," ORIGIN",[405,1323,1199],{"class":573},[405,1325,1326],{"class":586}," { x: ",[405,1328,1329],{"class":577},"40",[405,1331,1332],{"class":586},", y: ",[405,1334,1335],{"class":577},"60",[405,1337,1338],{"class":586}," };\n",[10,1340,1341],{},"同列保持用户原有的上下相对顺序。这一条是这类功能能不能用的分界线——整理完之后用户还得能认出自己的图。",[10,1343,1344],{},"节点上限 100，单批最大 50 项，展开后的总任务上限 200。这几个数决定了上面那个判断成立：在这个规模内，一条规则够用。",[17,1346,1348],{"id":1347},"五便签不能做成节点","五、便签不能做成节点",[10,1350,1351],{},"画布上要有地方写注释。最自然的做法是加一个「便签节点」，但它的实现方式正好相反：",[375,1353,1355],{"className":399,"code":1354,"language":401,"meta":315,"style":315},"\u002F**\n * 画布便签：纯注释，不参与调度、连线与计费。\n *\u002F\n",[178,1356,1357,1361,1366],{"__ignoreMap":315},[405,1358,1359],{"class":407,"line":408},[405,1360,501],{"class":411},[405,1362,1363],{"class":407,"line":316},[405,1364,1365],{"class":411}," * 画布便签：纯注释，不参与调度、连线与计费。\n",[405,1367,1368],{"class":407,"line":429},[405,1369,552],{"class":411},[375,1371,1373],{"className":399,"code":1372,"language":401,"meta":315,"style":315},"\u002F\u002F 不是 xyflow 节点——它不参与连线、调度与计费，做成节点要在注册表、校验器、执行器三处逐一开豁免，成本远高于一张自绘卡片。\n",[178,1374,1375],{"__ignoreMap":315},[405,1376,1377],{"class":407,"line":408},[405,1378,1372],{"class":411},[10,1380,1381],{},"做成节点意味着它要在三处地方被显式排除。而排除逻辑每加一处，将来就多一处要维护的例外。做成一张画在流坐标系里的自绘卡片，这些例外一个都不需要。",[10,1383,1384],{},"契约里限制条数 100、单条 2000 字。",[17,1386,1388],{"id":1387},"六批量框","六、批量框",[10,1390,1391],{},"批量框是「框内的子图跑 N 遍」。它带来的第一件事是边界规则：",[10,1393,1394],{},"框内节点的输出不能接到框外，反过来可以。",[375,1396,1398],{"className":399,"code":1397,"language":401,"meta":315,"style":315},"\u002F\u002F 批量框边界规则：框内节点的输出不能接到框外（或另一个框）。\n\u002F\u002F 框内子图每份各跑一遍，往外接意味着下游要收 N 份——运行时不支持这种收束；\n\u002F\u002F 反方向（框外 → 框内）合法：同一上游共享给每份拷贝。\n",[178,1399,1400,1405,1410],{"__ignoreMap":315},[405,1401,1402],{"class":407,"line":408},[405,1403,1404],{"class":411},"\u002F\u002F 批量框边界规则：框内节点的输出不能接到框外（或另一个框）。\n",[405,1406,1407],{"class":407,"line":316},[405,1408,1409],{"class":411},"\u002F\u002F 框内子图每份各跑一遍，往外接意味着下游要收 N 份——运行时不支持这种收束；\n",[405,1411,1412],{"class":407,"line":429},[405,1413,1414],{"class":411},"\u002F\u002F 反方向（框外 → 框内）合法：同一上游共享给每份拷贝。\n",[10,1416,1417],{},"拒绝时的文案要给下一步：",[363,1419,1420],{},[10,1421,1422],{},"批量框内的节点不能连到框外：框内每份各跑一遍，产物会直接进素材库。要串联处理就把目标节点也拖进框里。",[10,1424,1425],{},"第二件事是删除节点的连带处理。节点被删掉之后，批量框里会留下幽灵成员，而运行创建时会拒绝整张图：",[375,1427,1429],{"className":399,"code":1428,"language":401,"meta":315,"style":315},"\u002F**\n * 从所有批量框成员里剔除已删除的节点；成员清空的框一并删除。\n *\n * 删除节点必须同步清理：残留的幽灵成员会让 run-create 直接拒绝整张画布\n * （「批量框引用了图中不存在的节点」），用户面对的是一张再也跑不起来的图。\n *\u002F\n",[178,1430,1431,1435,1440,1444,1449,1454],{"__ignoreMap":315},[405,1432,1433],{"class":407,"line":408},[405,1434,501],{"class":411},[405,1436,1437],{"class":407,"line":316},[405,1438,1439],{"class":411}," * 从所有批量框成员里剔除已删除的节点；成员清空的框一并删除。\n",[405,1441,1442],{"class":407,"line":429},[405,1443,728],{"class":411},[405,1445,1446],{"class":407,"line":435},[405,1447,1448],{"class":411}," * 删除节点必须同步清理：残留的幽灵成员会让 run-create 直接拒绝整张画布\n",[405,1450,1451],{"class":407,"line":519},[405,1452,1453],{"class":411}," * （「批量框引用了图中不存在的节点」），用户面对的是一张再也跑不起来的图。\n",[405,1455,1456],{"class":407,"line":525},[405,1457,552],{"class":411},[10,1459,1460],{},"第三件事是撤销栈。批量框要进快照：",[375,1462,1464],{"className":399,"code":1463,"language":401,"meta":315,"style":315},"export interface GraphSnapshot {\n  readonly nodes: readonly CanvasFlowNode[];\n  readonly edges: readonly CanvasFlowEdge[];\n  readonly selected: readonly string[];\n  \u002F** 批量框。撤销\u002F重做要连它一起回放，否则撤销删框后节点回来了框没了 *\u002F\n  readonly batchGroups: readonly CanvasFlowBatchGroup[];\n}\n",[178,1465,1466,1479,1499,1515,1531,1536,1552],{"__ignoreMap":315},[405,1467,1468,1471,1474,1477],{"class":407,"line":408},[405,1469,1470],{"class":573},"export",[405,1472,1473],{"class":573}," interface",[405,1475,1476],{"class":596}," GraphSnapshot",[405,1478,872],{"class":586},[405,1480,1481,1484,1488,1490,1493,1496],{"class":407,"line":316},[405,1482,1483],{"class":573},"  readonly",[405,1485,1487],{"class":1486},"s4XuR"," nodes",[405,1489,1187],{"class":573},[405,1491,1492],{"class":573}," readonly",[405,1494,1495],{"class":596}," CanvasFlowNode",[405,1497,1498],{"class":586},"[];\n",[405,1500,1501,1503,1506,1508,1510,1513],{"class":407,"line":429},[405,1502,1483],{"class":573},[405,1504,1505],{"class":1486}," edges",[405,1507,1187],{"class":573},[405,1509,1492],{"class":573},[405,1511,1512],{"class":596}," CanvasFlowEdge",[405,1514,1498],{"class":586},[405,1516,1517,1519,1522,1524,1526,1529],{"class":407,"line":435},[405,1518,1483],{"class":573},[405,1520,1521],{"class":1486}," selected",[405,1523,1187],{"class":573},[405,1525,1492],{"class":573},[405,1527,1528],{"class":577}," string",[405,1530,1498],{"class":586},[405,1532,1533],{"class":407,"line":519},[405,1534,1535],{"class":411},"  \u002F** 批量框。撤销\u002F重做要连它一起回放，否则撤销删框后节点回来了框没了 *\u002F\n",[405,1537,1538,1540,1543,1545,1547,1550],{"class":407,"line":525},[405,1539,1483],{"class":573},[405,1541,1542],{"class":1486}," batchGroups",[405,1544,1187],{"class":573},[405,1546,1492],{"class":573},[405,1548,1549],{"class":596}," CanvasFlowBatchGroup",[405,1551,1498],{"class":586},[405,1553,1554],{"class":407,"line":531},[405,1555,1556],{"class":586},"}\n",[10,1558,1559],{},"第四件事是状态聚合。批量框内一个节点对应多行执行状态，显示取哪个：",[375,1561,1564],{"className":1562,"code":1563,"language":380},[378],"failed > running > pending > cancelled > succeeded > idle\n",[178,1565,1563],{"__ignoreMap":315},[10,1567,1568,1569,1572],{},"原先只让 ",[178,1570,1571],{},"failed"," 优先。结果是第一份先成功、第二份还在跑的时候，节点就提前显示成「成功」。",[17,1574,1576],{"id":1575},"七续跑不重复扣费","七、续跑不重复扣费",[10,1578,1579],{},"这一项是这八项里唯一涉及钱的。",[10,1581,1582],{},"失败或被取消的运行，可以续跑。实现方式不是「重跑一遍」：",[375,1584,1586],{"className":399,"code":1585,"language":401,"meta":315,"style":315},"\u002F**\n * 单节点重试：给失败\u002F被取消的运行造一个「续跑」运行。成功节点的行原样回填\n * （产物、billingRef、时间戳都保留）——executor 的调度器见到 succeeded 行\n * 会直接当作上游已就绪，不会重新执行，也就不会重复扣费；其余节点\n * （failed \u002F cancelled \u002F pending）重置成全新的 pending 行，正常调度重跑。\n * 不修改原运行：重试是一条新的 CanvasFlowRun，历史记录保持完整。\n *\u002F\n",[178,1587,1588,1592,1597,1602,1607,1612,1617],{"__ignoreMap":315},[405,1589,1590],{"class":407,"line":408},[405,1591,501],{"class":411},[405,1593,1594],{"class":407,"line":316},[405,1595,1596],{"class":411}," * 单节点重试：给失败\u002F被取消的运行造一个「续跑」运行。成功节点的行原样回填\n",[405,1598,1599],{"class":407,"line":429},[405,1600,1601],{"class":411}," * （产物、billingRef、时间戳都保留）——executor 的调度器见到 succeeded 行\n",[405,1603,1604],{"class":407,"line":435},[405,1605,1606],{"class":411}," * 会直接当作上游已就绪，不会重新执行，也就不会重复扣费；其余节点\n",[405,1608,1609],{"class":407,"line":519},[405,1610,1611],{"class":411}," * （failed \u002F cancelled \u002F pending）重置成全新的 pending 行，正常调度重跑。\n",[405,1613,1614],{"class":407,"line":525},[405,1615,1616],{"class":411}," * 不修改原运行：重试是一条新的 CanvasFlowRun，历史记录保持完整。\n",[405,1618,1619],{"class":407,"line":531},[405,1620,552],{"class":411},[10,1622,1623,1624,1627],{},"关键在于复用判定落在行状态上，而不是「这次运行是新是旧」。所以续跑不需要额外的豁免逻辑：被判成功的节点带着原来的 ",[178,1625,1626],{},"billingRef","，调度器看到它就不再执行。",[10,1629,1630],{},"预估只算子集，余额检查同理。接口层有幂等入口（按用户 + 请求 ID 查重）和归属校验。不可重试的两种情形给出明确文案：",[375,1632,1635],{"className":1633,"code":1634,"language":380},[378],"这次运行已全部成功，没有可重试的节点\n运行还没结束，等它终结后再重试\n",[178,1636,1634],{"__ignoreMap":315},[10,1638,1639],{},"界面上的按钮从「重试」改成「重试失败节点」，带一句说明：",[363,1641,1642],{},[10,1643,1644],{},"成功节点的产物直接沿用，只有失败的节点会重新执行并计费。",[17,1646,1648],{"id":1647},"八八项之间的关系","八、八项之间的关系",[10,1650,1651],{},"单独看每一项，都是常规功能。放在一起时，出现了几条贯穿的取舍：",[10,1653,1654,1657,1658,1660],{},[25,1655,1656],{},"状态的归属要单一。"," 画布 ID 放 URL；运行状态用同一套 ",[178,1659,1143],{},"；撤销栈是唯一的变更入口。三处都收成一个来源之后，「刷新之后看到什么」才有确定答案。",[10,1662,1663,1666],{},[25,1664,1665],{},"例外要少。"," 便签不做成节点，就是为了避免在注册表、校验器、执行器三处开豁免。每开一处例外，就多一处将来会忘记的地方。",[10,1668,1669,1672],{},[25,1670,1671],{},"别抢用户的操作。"," 没选节点时不拦 Cmd+C；整理布局保留同列原有顺序；批量框拒绝时给下一步而不是只报错。",[10,1674,1675,1678],{},[25,1676,1677],{},"涉及钱的判定落在状态上。"," 续跑复用靠行状态，不靠运行的新旧；批量份数由框上显式配置，预估与执行读同一个函数。",[10,1680,1681],{},"最后一条是这一天唯一和故障档案有关的部分。同一天里，批量链路翻出一处行约定矛盾和一处从未命中的推断分支，两处的成因都是「两侧各写一份」。八项补齐之后，取值入口都比之前更集中——这不是巧合，是同一件事的两个方向。",[1034,1683,1684],{},"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);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":315,"searchDepth":316,"depth":316,"links":1686},[1687,1688,1689,1690,1691,1692,1693,1694],{"id":1074,"depth":316,"text":1075},{"id":1124,"depth":316,"text":1125},{"id":1162,"depth":316,"text":1163},{"id":1245,"depth":316,"text":1246},{"id":1347,"depth":316,"text":1348},{"id":1387,"depth":316,"text":1388},{"id":1575,"depth":316,"text":1576},{"id":1647,"depth":316,"text":1648},"2026-08-20",{},"\u002F2026-08-20",{"title":1063,"description":1068},"2026-08-20-画布这一步深链运行历史与续跑","一次补齐八项画布操作：URL 深链、小地图、运行历史、复制粘贴、批量框、一键整理布局、便签，以及失败运行的单节点续跑。",[1702,1703,1704,1705,1706,1707],"画布","工作流","URL状态","撤销重做","批量执行","单节点重试","eA-RfotRTWBXXppeiZFtcguPXjtpnVRWrkWgI5LKfY0",{"id":1710,"title":1711,"body":1712,"column":326,"date":2750,"description":1716,"extension":328,"hero_image":329,"meta":2751,"navigation":331,"path":2752,"seo":2753,"series_id":329,"severity":329,"stem":2754,"summary":2755,"tags":2756,"__hash__":2762},"posts\u002F2026-08-08-同一张能力表抄了三份.md","同一张能力表，抄了三份",{"type":7,"value":1713,"toc":2742},[1714,1717,1720,1723,1727,1730,1881,1884,1890,1896,1899,1967,1970,1974,1977,1980,2002,2005,2008,2014,2029,2032,2036,2039,2042,2048,2051,2179,2182,2191,2194,2201,2412,2415,2419,2430,2433,2436,2497,2500,2561,2564,2597,2601,2604,2607,2613,2620,2623,2630,2637,2641,2644,2671,2677,2680,2730,2736,2739],[10,1715,1716],{},"用户反馈：设置里选完模型，分辨率档不对——Seedance-2.5 只有 480P 和 720P；另外 2.5 的上游支持到 29 秒，故事板那边应该跟着放开。",[10,1718,1719],{},"第一句说得对，2.5 确实只有 480p 和 720p。第二句也对，它确实支持 4~29 秒。",[10,1721,1722],{},"两条都对，说明我们发出去的档位和上游支持的对不上。查下去发现，同一份能力表被抄了三份。",[17,1724,1726],{"id":1725},"一三处各自漂移","一、三处各自漂移",[10,1728,1729],{},"后端有一张权威表，写清了每个模型支持哪些分辨率、哪些时长：",[375,1731,1733],{"className":399,"code":1732,"language":401,"meta":315,"style":315},"const MODEL_RESOLUTIONS: Record\u003CVideoModel, readonly VideoResolution[]> = {\n  \"seedance-2\": [\"480p\", \"720p\", \"1080p\", \"4k\"],\n  \"seedance-2-fast\": [\"480p\", \"720p\"],\n  \"seedance-2-mini\": [\"480p\", \"720p\"],\n  \"seedance-2.5\": [\"480p\", \"720p\"],\n  \"kling-v3\": [\"720p\", \"1080p\"],\n  \"minimax-h3\": [\"2k\", \"768p\"],\n};\n",[178,1734,1735,1770,1799,1814,1829,1844,1859,1876],{"__ignoreMap":315},[405,1736,1737,1739,1742,1744,1747,1750,1753,1756,1759,1762,1765,1768],{"class":407,"line":408},[405,1738,574],{"class":573},[405,1740,1741],{"class":577}," MODEL_RESOLUTIONS",[405,1743,1187],{"class":573},[405,1745,1746],{"class":596}," Record",[405,1748,1749],{"class":586},"\u003C",[405,1751,1752],{"class":596},"VideoModel",[405,1754,1755],{"class":586},", ",[405,1757,1758],{"class":573},"readonly",[405,1760,1761],{"class":596}," VideoResolution",[405,1763,1764],{"class":586},"[]> ",[405,1766,1767],{"class":573},"=",[405,1769,872],{"class":586},[405,1771,1772,1775,1778,1781,1783,1786,1788,1791,1793,1796],{"class":407,"line":316},[405,1773,1774],{"class":862},"  \"seedance-2\"",[405,1776,1777],{"class":586},": [",[405,1779,1780],{"class":862},"\"480p\"",[405,1782,1755],{"class":586},[405,1784,1785],{"class":862},"\"720p\"",[405,1787,1755],{"class":586},[405,1789,1790],{"class":862},"\"1080p\"",[405,1792,1755],{"class":586},[405,1794,1795],{"class":862},"\"4k\"",[405,1797,1798],{"class":586},"],\n",[405,1800,1801,1804,1806,1808,1810,1812],{"class":407,"line":429},[405,1802,1803],{"class":862},"  \"seedance-2-fast\"",[405,1805,1777],{"class":586},[405,1807,1780],{"class":862},[405,1809,1755],{"class":586},[405,1811,1785],{"class":862},[405,1813,1798],{"class":586},[405,1815,1816,1819,1821,1823,1825,1827],{"class":407,"line":435},[405,1817,1818],{"class":862},"  \"seedance-2-mini\"",[405,1820,1777],{"class":586},[405,1822,1780],{"class":862},[405,1824,1755],{"class":586},[405,1826,1785],{"class":862},[405,1828,1798],{"class":586},[405,1830,1831,1834,1836,1838,1840,1842],{"class":407,"line":519},[405,1832,1833],{"class":862},"  \"seedance-2.5\"",[405,1835,1777],{"class":586},[405,1837,1780],{"class":862},[405,1839,1755],{"class":586},[405,1841,1785],{"class":862},[405,1843,1798],{"class":586},[405,1845,1846,1849,1851,1853,1855,1857],{"class":407,"line":525},[405,1847,1848],{"class":862},"  \"kling-v3\"",[405,1850,1777],{"class":586},[405,1852,1785],{"class":862},[405,1854,1755],{"class":586},[405,1856,1790],{"class":862},[405,1858,1798],{"class":586},[405,1860,1861,1864,1866,1869,1871,1874],{"class":407,"line":531},[405,1862,1863],{"class":862},"  \"minimax-h3\"",[405,1865,1777],{"class":586},[405,1867,1868],{"class":862},"\"2k\"",[405,1870,1755],{"class":586},[405,1872,1873],{"class":862},"\"768p\"",[405,1875,1798],{"class":586},[405,1877,1878],{"class":407,"line":537},[405,1879,1880],{"class":586},"};\n",[10,1882,1883],{},"前端有一份手抄的副本。它早就漂了，而且漂在两个方向。",[10,1885,1886,1889],{},[25,1887,1888],{},"多出的档位。"," 设置页的取档逻辑是写死的规则：「H3 两档、其余一律四档」。于是 2.5、fast、mini 这三个只有 480p 和 720p 的模型，界面上给出 1080P 和 4K。用户选了，选中即报未配价，或者直接被上游拒。",[10,1891,1892,1895],{},[25,1893,1894],{},"少掉的档位。"," Kling 只支持 720p 和 1080p，界面给的是四档。反过来的情况也存在：如果某个模型的档位比默认四档更多，界面也显示不出来。",[10,1897,1898],{},"时长那一处漂得更彻底。前端和后端各写了一份 4~15 的夹取：",[375,1900,1902],{"className":399,"code":1901,"language":401,"meta":315,"style":315},"\u002F\u002F 前端\nreturn Math.max(4, Math.min(15, value));\n\n\u002F\u002F 后端\nreturn Math.max(4, Math.min(15, value));\n",[178,1903,1904,1909,1936,1940,1945],{"__ignoreMap":315},[405,1905,1906],{"class":407,"line":408},[405,1907,1908],{"class":411},"\u002F\u002F 前端\n",[405,1910,1911,1914,1916,1919,1921,1924,1926,1928,1930,1933],{"class":407,"line":316},[405,1912,1913],{"class":573},"return",[405,1915,593],{"class":586},[405,1917,1918],{"class":596},"max",[405,1920,600],{"class":586},[405,1922,1923],{"class":577},"4",[405,1925,606],{"class":586},[405,1927,597],{"class":596},[405,1929,600],{"class":586},[405,1931,1932],{"class":577},"15",[405,1934,1935],{"class":586},", value));\n",[405,1937,1938],{"class":407,"line":429},[405,1939,906],{"emptyLinePlaceholder":331},[405,1941,1942],{"class":407,"line":435},[405,1943,1944],{"class":411},"\u002F\u002F 后端\n",[405,1946,1947,1949,1951,1953,1955,1957,1959,1961,1963,1965],{"class":407,"line":519},[405,1948,1913],{"class":573},[405,1950,593],{"class":586},[405,1952,1918],{"class":596},[405,1954,600],{"class":586},[405,1956,1923],{"class":577},[405,1958,606],{"class":586},[405,1960,597],{"class":596},[405,1962,600],{"class":586},[405,1964,1932],{"class":577},[405,1966,1935],{"class":586},[10,1968,1969],{},"2.5 传 29 秒，被悄悄砍成 15。用户看不出为什么变短——没有任何提示，界面上显示的就是 15 秒。",[17,1971,1973],{"id":1972},"二被砍掉的秒数后面跟着钱","二、被砍掉的秒数后面跟着钱",[10,1975,1976],{},"时长那一处还不只是显示问题。",[10,1978,1979],{},"故事板按固定秒数切板。代码里是一个常量：",[375,1981,1983],{"className":399,"code":1982,"language":401,"meta":315,"style":315},"export const SHOT_SECONDS = 15;\n",[178,1984,1985],{"__ignoreMap":315},[405,1986,1987,1989,1992,1995,1997,2000],{"class":407,"line":408},[405,1988,1470],{"class":573},[405,1990,1991],{"class":573}," const",[405,1993,1994],{"class":577}," SHOT_SECONDS",[405,1996,1199],{"class":573},[405,1998,1999],{"class":577}," 15",[405,2001,1204],{"class":586},[10,2003,2004],{},"按 15 秒一切。2.5 一板能放 29 秒，硬按 15 秒切，一集会被切成两倍数量的板。",[10,2006,2007],{},"板数翻倍就是出图与出片的费用翻倍。这不是理论推算——用户选 2.5 的动机就是长板数少切，结果切得和短时长模型一样多，还多花一倍钱。",[10,2009,2010,2013],{},[178,2011,2012],{},"comic-subshot.ts"," 里留着上限常量的注释，写明了它只是「模型未知时的保守默认」：",[375,2015,2017],{"className":399,"code":2016,"language":401,"meta":315,"style":315},"\u002F\u002F 上限只是「模型未知时的保守默认」——真正的上限逐模型不同（seedance-2.5 能到 29 秒），\n\u002F\u002F 调用方应把该模型的最大秒数作为 maxBoardSec 传进来。写死 15 的话，选了 2.5 也只切 15 秒一板。\n",[178,2018,2019,2024],{"__ignoreMap":315},[405,2020,2021],{"class":407,"line":408},[405,2022,2023],{"class":411},"\u002F\u002F 上限只是「模型未知时的保守默认」——真正的上限逐模型不同（seedance-2.5 能到 29 秒），\n",[405,2025,2026],{"class":407,"line":316},[405,2027,2028],{"class":411},"\u002F\u002F 调用方应把该模型的最大秒数作为 maxBoardSec 传进来。写死 15 的话，选了 2.5 也只切 15 秒一板。\n",[10,2030,2031],{},"调用方没传。",[17,2033,2035],{"id":2034},"三提示词长度四个数一个是实测出来的","三、提示词长度：四个数，一个是实测出来的",[10,2037,2038],{},"同一批里还有一个更贵的限制：提示词长度上限。",[10,2040,2041],{},"上游对提示词有硬上限，超过直接拒。各模型不一样，而这些值在很长一段时间里没有集中维护。表现是一条线上的完整失败：",[375,2043,2046],{"className":2044,"code":2045,"language":380},[378],"模型 seedance-2.5 的提示词不能超过 5000 个字符，当前为 18545 个字符\n",[178,2047,2045],{"__ignoreMap":315},[10,2049,2050],{},"接入本身没问题，是缺了长度约束。补完之后这张表长这样：",[375,2052,2054],{"className":399,"code":2053,"language":401,"meta":315,"style":315},"const MODEL_PROMPT_LIMITS: Partial\u003CRecord\u003CVideoModel, number>> = {\n  \"minimax-h3\": 7000,\n  \"seedance-2\": 2500,\n  \"seedance-2-fast\": 2500,\n  \"seedance-2-mini\": 2500,\n  \"seedance-2.5\": 5000,\n  \u002F\u002F 20260809 实测：发 2553 字被硬拒 `prompt: size must be between 0 and 2500`\n  \"kling-v3\": 2500,\n};\nexport const PROMPT_LIMIT_FALLBACK = 50000;\n",[178,2055,2056,2089,2102,2113,2123,2133,2144,2149,2159,2163],{"__ignoreMap":315},[405,2057,2058,2060,2063,2065,2068,2070,2073,2075,2077,2079,2082,2085,2087],{"class":407,"line":408},[405,2059,574],{"class":573},[405,2061,2062],{"class":577}," MODEL_PROMPT_LIMITS",[405,2064,1187],{"class":573},[405,2066,2067],{"class":596}," Partial",[405,2069,1749],{"class":586},[405,2071,2072],{"class":596},"Record",[405,2074,1749],{"class":586},[405,2076,1752],{"class":596},[405,2078,1755],{"class":586},[405,2080,2081],{"class":577},"number",[405,2083,2084],{"class":586},">> ",[405,2086,1767],{"class":573},[405,2088,872],{"class":586},[405,2090,2091,2093,2096,2099],{"class":407,"line":316},[405,2092,1863],{"class":862},[405,2094,2095],{"class":586},": ",[405,2097,2098],{"class":577},"7000",[405,2100,2101],{"class":586},",\n",[405,2103,2104,2106,2108,2111],{"class":407,"line":429},[405,2105,1774],{"class":862},[405,2107,2095],{"class":586},[405,2109,2110],{"class":577},"2500",[405,2112,2101],{"class":586},[405,2114,2115,2117,2119,2121],{"class":407,"line":435},[405,2116,1803],{"class":862},[405,2118,2095],{"class":586},[405,2120,2110],{"class":577},[405,2122,2101],{"class":586},[405,2124,2125,2127,2129,2131],{"class":407,"line":519},[405,2126,1818],{"class":862},[405,2128,2095],{"class":586},[405,2130,2110],{"class":577},[405,2132,2101],{"class":586},[405,2134,2135,2137,2139,2142],{"class":407,"line":525},[405,2136,1833],{"class":862},[405,2138,2095],{"class":586},[405,2140,2141],{"class":577},"5000",[405,2143,2101],{"class":586},[405,2145,2146],{"class":407,"line":531},[405,2147,2148],{"class":411},"  \u002F\u002F 20260809 实测：发 2553 字被硬拒 `prompt: size must be between 0 and 2500`\n",[405,2150,2151,2153,2155,2157],{"class":407,"line":537},[405,2152,1848],{"class":862},[405,2154,2095],{"class":586},[405,2156,2110],{"class":577},[405,2158,2101],{"class":586},[405,2160,2161],{"class":407,"line":543},[405,2162,1880],{"class":586},[405,2164,2165,2167,2169,2172,2174,2177],{"class":407,"line":549},[405,2166,1470],{"class":573},[405,2168,1991],{"class":573},[405,2170,2171],{"class":577}," PROMPT_LIMIT_FALLBACK",[405,2173,1199],{"class":573},[405,2175,2176],{"class":577}," 50000",[405,2178,1204],{"class":586},[10,2180,2181],{},"这几个数的来源不同，注释里写清了哪个是实测的：",[363,2183,2184],{},[10,2185,2186,2187,2190],{},"seedance-2.5 的 5000 是",[25,2188,2189],{},"实测出来的，文档只字未提","：线上一条 18545 字的脚本被拒，报文写「提示词不能超过 5000 个字符」。同批实测另两条渠道收 16500 字照样 200，所以这是 2.5 独有的限制，不能推广到整个系列。加新渠道前先拿超长 prompt 打一次，别等线上炸。",[10,2192,2193],{},"这段话是这张表里唯一带出处的一条。其余几个数是按上游文档填的，文档没提的只能等线上撞。",[10,2195,2196,2197,2200],{},"长度约束补齐之后，还有一个配套的预算计算。原先只有 H3 有预算，其余模型返回 ",[178,2198,2199],{},"null","，等于完全不限：",[375,2202,2204],{"className":399,"code":2203,"language":401,"meta":315,"style":315},"\u002F** 每秒成片对应的脚本篇幅。15 秒 → 7500 字，是人肉审稿与出片效果都合适的密度。 *\u002F\nexport const CHARS_PER_SECOND = 500;\n\u002F** 留给用户自己追加修改的余量：脚本刚好顶满上限时，用户加一句就被上游拒了。 *\u002F\nconst PROMPT_BUDGET_MARGIN = 500;\n\nexport function scriptCharBudget(targetModel: string | undefined, durationSec?: number): number | null {\n  const hardLimit = modelPromptHardLimit(targetModel);\n  if (!durationSec || !Number.isFinite(durationSec) || durationSec \u003C= 0) return hardLimit;\n  const byDuration = Math.round(durationSec * CHARS_PER_SECOND);\n  return hardLimit ? Math.min(byDuration, hardLimit) : byDuration;\n}\n",[178,2205,2206,2211,2227,2232,2245,2249,2297,2313,2361,2384,2407],{"__ignoreMap":315},[405,2207,2208],{"class":407,"line":408},[405,2209,2210],{"class":411},"\u002F** 每秒成片对应的脚本篇幅。15 秒 → 7500 字，是人肉审稿与出片效果都合适的密度。 *\u002F\n",[405,2212,2213,2215,2217,2220,2222,2225],{"class":407,"line":316},[405,2214,1470],{"class":573},[405,2216,1991],{"class":573},[405,2218,2219],{"class":577}," CHARS_PER_SECOND",[405,2221,1199],{"class":573},[405,2223,2224],{"class":577}," 500",[405,2226,1204],{"class":586},[405,2228,2229],{"class":407,"line":429},[405,2230,2231],{"class":411},"\u002F** 留给用户自己追加修改的余量：脚本刚好顶满上限时，用户加一句就被上游拒了。 *\u002F\n",[405,2233,2234,2236,2239,2241,2243],{"class":407,"line":435},[405,2235,574],{"class":573},[405,2237,2238],{"class":577}," PROMPT_BUDGET_MARGIN",[405,2240,1199],{"class":573},[405,2242,2224],{"class":577},[405,2244,1204],{"class":586},[405,2246,2247],{"class":407,"line":519},[405,2248,906],{"emptyLinePlaceholder":331},[405,2250,2251,2253,2256,2259,2261,2264,2266,2268,2270,2273,2275,2278,2281,2284,2287,2289,2291,2293,2295],{"class":407,"line":525},[405,2252,1470],{"class":573},[405,2254,2255],{"class":573}," function",[405,2257,2258],{"class":596}," scriptCharBudget",[405,2260,600],{"class":586},[405,2262,2263],{"class":1486},"targetModel",[405,2265,1187],{"class":573},[405,2267,1528],{"class":577},[405,2269,1193],{"class":573},[405,2271,2272],{"class":577}," undefined",[405,2274,1755],{"class":586},[405,2276,2277],{"class":1486},"durationSec",[405,2279,2280],{"class":573},"?:",[405,2282,2283],{"class":577}," number",[405,2285,2286],{"class":586},")",[405,2288,1187],{"class":573},[405,2290,2283],{"class":577},[405,2292,1193],{"class":573},[405,2294,1196],{"class":577},[405,2296,872],{"class":586},[405,2298,2299,2302,2305,2307,2310],{"class":407,"line":531},[405,2300,2301],{"class":573},"  const",[405,2303,2304],{"class":577}," hardLimit",[405,2306,1199],{"class":573},[405,2308,2309],{"class":596}," modelPromptHardLimit",[405,2311,2312],{"class":586},"(targetModel);\n",[405,2314,2315,2318,2321,2324,2327,2330,2333,2336,2339,2342,2344,2347,2350,2353,2356,2358],{"class":407,"line":537},[405,2316,2317],{"class":573},"  if",[405,2319,2320],{"class":586}," (",[405,2322,2323],{"class":573},"!",[405,2325,2326],{"class":586},"durationSec ",[405,2328,2329],{"class":573},"||",[405,2331,2332],{"class":573}," !",[405,2334,2335],{"class":586},"Number.",[405,2337,2338],{"class":596},"isFinite",[405,2340,2341],{"class":586},"(durationSec) ",[405,2343,2329],{"class":573},[405,2345,2346],{"class":586}," durationSec ",[405,2348,2349],{"class":573},"\u003C=",[405,2351,2352],{"class":577}," 0",[405,2354,2355],{"class":586},") ",[405,2357,1913],{"class":573},[405,2359,2360],{"class":586}," hardLimit;\n",[405,2362,2363,2365,2368,2370,2372,2375,2378,2380,2382],{"class":407,"line":543},[405,2364,2301],{"class":573},[405,2366,2367],{"class":577}," byDuration",[405,2369,1199],{"class":573},[405,2371,593],{"class":586},[405,2373,2374],{"class":596},"round",[405,2376,2377],{"class":586},"(durationSec ",[405,2379,615],{"class":573},[405,2381,2219],{"class":577},[405,2383,896],{"class":586},[405,2385,2386,2389,2392,2395,2397,2399,2402,2404],{"class":407,"line":549},[405,2387,2388],{"class":573},"  return",[405,2390,2391],{"class":586}," hardLimit ",[405,2393,2394],{"class":573},"?",[405,2396,593],{"class":586},[405,2398,597],{"class":596},[405,2400,2401],{"class":586},"(byDuration, hardLimit) ",[405,2403,1187],{"class":573},[405,2405,2406],{"class":586}," byDuration;\n",[405,2408,2410],{"class":407,"line":2409},11,[405,2411,1556],{"class":586},[10,2413,2414],{},"按 15 秒算出来是 7500 字——上限撤销之后，脚本直接写到 1.8 万字，那也是生成耗时 193 秒的原因。",[17,2416,2418],{"id":2417},"四抄一份是必要的那就测试它","四、抄一份是必要的，那就测试它",[10,2420,2421,2422,2425,2426,2429],{},"前端为什么不能直接读后端那张表：",[178,2423,2424],{},"apps\u002Fweb"," 不能 import ",[178,2427,2428],{},"apps\u002Fapi","。而节点面板必须知道「这个模型支持哪些分辨率、哪些时长」，才能只给出跑得通的选项。",[10,2431,2432],{},"抄一份是必要的。那就把「不许走样」变成断言。",[10,2434,2435],{},"新增的共享包文件头写明了它的性质和守卫：",[375,2437,2439],{"className":399,"code":2438,"language":401,"meta":315,"style":315},"\u002F**\n * 生图 \u002F 视频的**上游真实能力**表。\n *\n * 这是 apps\u002Fapi 里两张表的镜像……\n *\n * **为什么要抄一份**：前端（apps\u002Fweb）不能 import apps\u002Fapi，而节点面板必须知道\n * 「这个模型支持哪些分辨率、哪些时长」才能只给出跑得通的选项。\n * 抄一份就有走样的风险，所以 apps\u002Fapi 里有一条对比测试逐项核对两边——\n * 谁改了上游表而没同步这里，测试立刻红。\n *\n * 硬规矩：这里只允许出现上游真支持的取值。多给一个选项，用户就会选到一个必然失败的组合。\n *\u002F\n",[178,2440,2441,2445,2450,2454,2459,2463,2468,2473,2478,2483,2487,2492],{"__ignoreMap":315},[405,2442,2443],{"class":407,"line":408},[405,2444,501],{"class":411},[405,2446,2447],{"class":407,"line":316},[405,2448,2449],{"class":411}," * 生图 \u002F 视频的**上游真实能力**表。\n",[405,2451,2452],{"class":407,"line":429},[405,2453,728],{"class":411},[405,2455,2456],{"class":407,"line":435},[405,2457,2458],{"class":411}," * 这是 apps\u002Fapi 里两张表的镜像……\n",[405,2460,2461],{"class":407,"line":519},[405,2462,728],{"class":411},[405,2464,2465],{"class":407,"line":525},[405,2466,2467],{"class":411}," * **为什么要抄一份**：前端（apps\u002Fweb）不能 import apps\u002Fapi，而节点面板必须知道\n",[405,2469,2470],{"class":407,"line":531},[405,2471,2472],{"class":411}," * 「这个模型支持哪些分辨率、哪些时长」才能只给出跑得通的选项。\n",[405,2474,2475],{"class":407,"line":537},[405,2476,2477],{"class":411}," * 抄一份就有走样的风险，所以 apps\u002Fapi 里有一条对比测试逐项核对两边——\n",[405,2479,2480],{"class":407,"line":543},[405,2481,2482],{"class":411}," * 谁改了上游表而没同步这里，测试立刻红。\n",[405,2484,2485],{"class":407,"line":549},[405,2486,728],{"class":411},[405,2488,2489],{"class":407,"line":2409},[405,2490,2491],{"class":411}," * 硬规矩：这里只允许出现上游真支持的取值。多给一个选项，用户就会选到一个必然失败的组合。\n",[405,2493,2495],{"class":407,"line":2494},12,[405,2496,552],{"class":411},[10,2498,2499],{},"同时把取值这件事收成单点：",[375,2501,2503],{"className":399,"code":2502,"language":401,"meta":315,"style":315},"\u002F**\n * 前端渲染面板、后端校验参数都走这一个函数——两边各写一份判断，\n * 迟早出现「界面给得出、服务端不认」的组合。\n *\u002F\nexport function resolveDynamicOptions(kind: \"videoResolution\" | \"videoDuration\", params): readonly { value, label }[]\n",[178,2504,2505,2509,2514,2519,2523],{"__ignoreMap":315},[405,2506,2507],{"class":407,"line":408},[405,2508,501],{"class":411},[405,2510,2511],{"class":407,"line":316},[405,2512,2513],{"class":411}," * 前端渲染面板、后端校验参数都走这一个函数——两边各写一份判断，\n",[405,2515,2516],{"class":407,"line":429},[405,2517,2518],{"class":411}," * 迟早出现「界面给得出、服务端不认」的组合。\n",[405,2520,2521],{"class":407,"line":435},[405,2522,552],{"class":411},[405,2524,2525,2527,2529,2532,2534,2537,2539,2542,2544,2547,2549,2552,2554,2556,2558],{"class":407,"line":519},[405,2526,1470],{"class":573},[405,2528,2255],{"class":573},[405,2530,2531],{"class":596}," resolveDynamicOptions",[405,2533,600],{"class":586},[405,2535,2536],{"class":1486},"kind",[405,2538,1187],{"class":573},[405,2540,2541],{"class":862}," \"videoResolution\"",[405,2543,1193],{"class":573},[405,2545,2546],{"class":862}," \"videoDuration\"",[405,2548,1755],{"class":586},[405,2550,2551],{"class":1486},"params",[405,2553,2286],{"class":586},[405,2555,1187],{"class":573},[405,2557,1492],{"class":573},[405,2559,2560],{"class":586}," { value, label }[]\n",[10,2562,2563],{},"细粒度那一档也保留了两份清单，且是有意不同：",[375,2565,2567],{"className":399,"code":2566,"language":401,"meta":315,"style":315},"\u002F**\n * 注意它与下面的 MODEL_DURATION_OPTIONS 是两回事、且**故意不同**：\n * 后者是「按钮行」的精简清单（seedance 只列 7 档，避免一排按钮太长），\n * 而滑块是细粒度选择，应当覆盖服务端真正接受的全部档位——\n * 否则用户拖不到 7\u002F9\u002F11\u002F13\u002F14 秒，白白削掉能力。\n *\u002F\n",[178,2568,2569,2573,2578,2583,2588,2593],{"__ignoreMap":315},[405,2570,2571],{"class":407,"line":408},[405,2572,501],{"class":411},[405,2574,2575],{"class":407,"line":316},[405,2576,2577],{"class":411}," * 注意它与下面的 MODEL_DURATION_OPTIONS 是两回事、且**故意不同**：\n",[405,2579,2580],{"class":407,"line":429},[405,2581,2582],{"class":411}," * 后者是「按钮行」的精简清单（seedance 只列 7 档，避免一排按钮太长），\n",[405,2584,2585],{"class":407,"line":435},[405,2586,2587],{"class":411}," * 而滑块是细粒度选择，应当覆盖服务端真正接受的全部档位——\n",[405,2589,2590],{"class":407,"line":519},[405,2591,2592],{"class":411}," * 否则用户拖不到 7\u002F9\u002F11\u002F13\u002F14 秒，白白削掉能力。\n",[405,2594,2595],{"class":407,"line":525},[405,2596,552],{"class":411},[17,2598,2600],{"id":2599},"五假绿的七条用例","五、假绿的七条用例",[10,2602,2603],{},"修的过程中还撞到一个测试问题，值得单独记。",[10,2605,2606],{},"改动完成后跑测试，有一组用例报了这个警告：",[375,2608,2611],{"className":2609,"code":2610,"language":380},[378],"This might cause false positive tests\n",[178,2612,2610],{"__ignoreMap":315},[10,2614,2615,2616,2619],{},"追下去发现是真的假绿。供应商迁移之后，配置加载在某些条件下会抛错，而那个抛错发生在 ",[178,2617,2618],{},"try"," 之外，成了未处理的 rejection。用例本身并不感知异常，于是照样通过。",[10,2621,2622],{},"具体是 7 条。",[10,2624,2625,2626,2629],{},"修法是让配置加载把异常收敛成一个明确的错误类型（缺 key 重试没有意义，归为永久失败），并在两个相关测试文件的模块加载阶段注入测试用 key。注入位置也有讲究——放 ",[178,2627,2628],{},"beforeEach"," 会因为跨文件执行顺序失效。",[10,2631,2632,2633,2636],{},"这件事和主题有关：",[25,2634,2635],{},"同一份能力表抄三份会漂，同一份配置在三个地方加载也会。"," 假绿的七条用例，测的是「配置能加载」，而配置在测试环境里根本没被加载。",[17,2638,2640],{"id":2639},"六收口之后","六、收口之后",[10,2642,2643],{},"同一批里还顺手修了两处同源问题：",[55,2645,2646,2657],{},[58,2647,2648,2649,2652,2653,2656],{},"漫剧设置页那份模型列表是前端手写的副本，早已和后端漂开：列出「Seedance-2.0 Pro」和「可灵 V2」两个根本不存在的 ID，而真实的 ID 是 ",[178,2650,2651],{},"seedance-2-fast"," 和 ",[178,2654,2655],{},"kling-v3","。选中即被出片接口的 zod 拒掉。改成读取后端的模型清单接口。",[58,2658,2659,2660,1114,2663,2666,2667,2670],{},"长篇项目的设置页没有把 ",[178,2661,2662],{},"videoModel",[178,2664,2665],{},"videoResolution"," 传给设置组件，",[178,2668,2669],{},"onSave"," 也没往上收。用户选完模型点保存，没有报错，刷新之后回退到原来的值。",[10,2672,2673,2674,28],{},"三处问题的形式不同，成因是同一个：",[25,2675,2676],{},"同一份事实存在多个副本，而且没有一处是权威",[10,2678,2679],{},"收口之后的结构是三份，每一份都有明确职责：",[95,2681,2682,2695],{},[98,2683,2684],{},[101,2685,2686,2689,2692],{},[104,2687,2688],{},"位置",[104,2690,2691],{},"职责",[104,2693,2694],{},"守卫",[114,2696,2697,2708,2719],{},[101,2698,2699,2702,2705],{},[119,2700,2701],{},"后端能力表",[119,2703,2704],{},"权威来源",[119,2706,2707],{},"被镜像表逐项对比",[101,2709,2710,2713,2716],{},[119,2711,2712],{},"共享包镜像表",[119,2714,2715],{},"跨端取值",[119,2717,2718],{},"对比测试（改一边不改另一边即红）",[101,2720,2721,2724,2727],{},[119,2722,2723],{},"前端 UI 清单",[119,2725,2726],{},"只影响展示形态",[119,2728,2729],{},"值必须来自共享包，不许写字面量",[10,2731,2732,2733],{},"硬规矩只有一条，写在镜像表里：",[25,2734,2735],{},"只允许出现上游真支持的取值。多给一个选项，用户就会选到一个必然失败的组合。",[10,2737,2738],{},"反过来说，少给一个选项的代价同样实在——2.5 的 29 秒被砍到 15，用户看到的是「这个模型没比别的强」，看不到的是我们没把它的能力交出去。",[1034,2740,2741],{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}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);}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":315,"searchDepth":316,"depth":316,"links":2743},[2744,2745,2746,2747,2748,2749],{"id":1725,"depth":316,"text":1726},{"id":1972,"depth":316,"text":1973},{"id":2034,"depth":316,"text":2035},{"id":2417,"depth":316,"text":2418},{"id":2599,"depth":316,"text":2600},{"id":2639,"depth":316,"text":2640},"2026-08-08",{},"\u002F2026-08-08",{"title":1711,"description":1716},"2026-08-08-同一张能力表抄了三份","上游模型的分辨率、时长、提示词长度散落在三处各自维护，各自漂移。一次用户反馈把三处一起翻出来：多出的档位、被砍掉的秒数、翻倍的板数。",[2757,2758,2759,2760,2761],"模型能力","配置漂移","契约","前端后端一致","提示词长度","xrC9lTC00ghKpLq5ZuDyYhLFe2fpcs3SgCXkd9QeigE",1789212573234]