01 · 主循环全景
沿一条消息走完一次 Agent 循环
点步骤或框图节点逐层聚焦;只有当前环节的数据流会流动。每个节点都对应一个真实源文件。
02 · 两段式意图路由
规则先手,只在拿不准时才花一次 LLM
下面的输入框完整复刻 intent-router.ts 的五域正则打分(同一套正则、同一套权重):
打字看分数怎么涨、主域怎么选、辅助域怎么补、置信度到多少才会触发 intent-llm.ts 的复核。
输入一句话试试
五域打分
rationale:—
规则打分 intent-router.ts
| +3 | content_write:新建/创建/写入/删除/移动/迁移/跨库/重命名/归档/发布/撤销分享/改标题/更新正文… |
| +3 | admin:模型配置 / ai 配置 / 密钥 / api key / 公开问答 / 吊销 / 配额 / 开关 |
| +2 | knowledge:知识库 / 文章 / wiki / 笔记 |
| +2 | doc_library:文档 / 文件 / pdf / word / excel / csv |
| +2 | system:多少 / 几个 / 统计 / 概览 / 状态 / 总数 / 系统 / 是否就绪 |
| +4 | focus 指着的实体所属域(FOCUS_DOMAIN_BOOST) |
| +1 | 本会话最近用过的每个工具,按其注册域加分 |
| 取前 2 | MAX_PRIMARY_INTENT_DOMAINS:主域最多 2 个,辅助域另计 |
| 置信度 | min(0.9, 0.5 + 信号数 × 0.1);无信号回退只读三域,0.3 |
LLM 复核 intent-llm.ts
只在两种情况下才花这一次调用:置信度 < 0.5,或规则命中 admin。
| generateObject | zod schema 锁死 { domains(1–2), confidence, rationale ≤80 字 },temperature 0 |
| 5s 超时 | AbortSignal.timeout 与请求 signal 取 any;超时/失败/中断静默回退规则结果 |
| admin 保底 | 规则命中 admin 时 LLM 不得剥掉(mergeAdminDomainFromRules);LLM 失败也保留,rationale 追加 admin-domain-kept-* |
| admin 粘性 | 上一轮 run 的意图域含 admin 时本轮继续挂载;content_write 已常驻,不再做粘性 |
| 并行 | 意图复核与 ContextPack 用 Promise.all 并行,不串行阻塞首 token |
| 确认 resume | 只回传确认、未追加新 user 消息时整段跳过(rationale 追加 confirmation-resume) |
意图 ≠ 装载 domain-types.ts
对齐 Claude Code:小工具集常驻,不靠意图硬门控藏工具。意图芯片仍展示路由结果,但实际装载走另一条:
| 常驻四域 | system · knowledge · doc_library · content_write,每轮都装 |
| 按需一域 | admin 只在意图命中时装载 |
| 补域规则 | knowledge/doc_library → 补 system;admin → 补 content_write(要用确认工具) |
| 步数按意图 | 意图含 content_write 或 admin → maxSteps 20,否则 12——用意图芯片而非常驻域,避免永远按写档给步数 |
| 步数预算条 | 工具调用数达 maxSteps - 1 推 warning(不落库);用尽推 exhausted(落库,刷新后仍提示续跑) |
03 · 上下文工程实验室
亲手拖一拖:对话变长时上下文如何折叠
下面的滑杆完整复刻了 context-pack.ts
里的窗口与触发算法(不是示意,是同一套逻辑):拖动消息条数、切换消息长度,看窗口如何收缩、折叠何时触发、召回何时激活、旧轮次的工具结果何时被压成一行摘要。
模拟一场变长的对话
消息时间线
窗口与折叠 context-pack.ts
| 100k tokens | 每轮上下文总预算 MAX_CONTEXT_TOKENS |
| 35% | 最近原文最多占预算比例,逐条试算得出动态窗口 6–20 条 |
| fixed / token_budget / turn_budget | 窗口收敛原因三态:触下限 6 / 卡 token / 触上限 20 |
| 55% 或 >20 条 | 任一命中即触发折叠:token 估算超预算 55%,或落库消息超 20 条 |
| 字符数 ÷ 2 | token 粗估:JSON.stringify(messages).length / 2,只用于触发阈值 |
| 8s / 8000 字 | 摘要超时与长度上限;超时不阻塞回答,下轮再试 |
| 24000 字 | 送进压缩器的折叠区转写上限 buildFoldableTranscript |
| 水位 | 成功后写 contextSummaryUntilMessageId(排除最近 N 条后的最大 message.id) |
工具结果压缩 compactToolParts
折叠之前先做一层更便宜的裁剪:只有最近 2 个用户轮次的 tool 结果保留全文,更早的压成一行。
| keepRecentTurns 2 | 从后往前数 2 个 role=user 的下标作为截断点 |
| 保留字段 | toolName、ok、errorCode,以及 id 类字段:id / articleId / documentId / knowledgeBaseId / libraryId / confirmationId / planId / shareId / configId |
| message 截断 | 没有 id 时退而保留 message 或 error 前 120 字 |
| 顺序 | 压缩 → 算窗口 → 切分 → 折叠摘要;窗口试算基于压缩后的消息,省下的 token 直接换成更多原文 |
向量召回 context-recall.ts
| top 4 · ≥0.25 | pgvector 余弦相似度召回条数与阈值,排除窗口内消息 id |
| 激活条件 | 落库消息数 > 窗口条数即激活(与折叠是否触发无关) |
| 16 条/批 · ≤500 字 | 缺向量的历史消息在后台异步补齐,不阻塞本轮召回 |
| [redacted] | 入库前清洗:sk-*、api_key=、Bearer、确认卡明文一律脱敏 |
| best effort | 无嵌入配置、SQLite 环境或查询失败时静默跳过,绝不拖垮回答 |
向量这条线还有一整套自己的故事,单独拆在 《向量化 · 入库索引 · 召回》。
04 · 工具装配与 Skills
36 个工具常驻四域,playbook 按需展开
工具列表要小而稳,业务流程要长而细——两者矛盾,于是拆开:工具常驻装载,
skill 只把目录塞进系统提示词,模型自己判断要不要 load_skill 取全文。
工具面板:36 个工具,五个域
核心四域每轮常驻,admin 按意图装载;
虚线红 的 dangerous 工具永远不出现在模型的工具列表里,只能经确认闸门由运行时执行;
带 🔒 的 requiresOperator 工具只装给操作员,其余用户完全看不到(详见第 07 节)。
Skills 渐进披露 skills/index.ts · load_skill
四个内置 playbook,按装载域过滤后只把「名字 + 一句话」写进系统提示词,正文要用才取:
| knowledge-qa | 知识库/文章问答与引用(域:knowledge) |
| doc-library-qa | 文档库检索与片段定位(域:doc_library) |
| article-write | 写改/移动文章、分享与危险删除(域:content_write) |
| admin-ops | 模型配置 / API Key / 公开问答(域:admin) |
| load_skill | 按 name 取全文;未命中返回 skill_not_found 并附可用清单 |
| 操作员覆盖 | 同名操作员 skill 覆盖内置;该表现已只读(写入路径随面板移除) |
系统提示词怎么拼 system-prompt.ts
提示词是按本轮装载域拼出来的,不是一份写死的长文:
- 操作员记忆段(若是操作员,见第 07 节)拼在最前
- 身份 + 「本轮装载域:…只调用本轮实际提供的工具」
- 按域追加指引:system → 进度/计划/子代理/记忆;knowledge → 图片渲染、跨库检索要带
hits[].knowledgeBaseId、关联型问题先走星图;content_write → 移动用move_article、大改先 preview;admin/content_write → 危险操作必须出确认卡 - skill 目录清单
- 兜底铁律:以工具结果为准、软失败按
action换招、熔断工具不再调、只用中文 - 末尾接上下文摘要与向量召回片段(
buildInstructionsWithContextExtras)
05 · 子代理体系
三种子代理,三套不一样的约束
子代理是独立的 Agent 实例(独立提示词、独立工具过滤),但共享主 run 落档,步骤名带
spawn_*/d{depth} 前缀,回放时能看清委派链。共同铁律:子代理永远碰不到写入与确认工具。
研究子代理 spawn_research_subagent
只读深度检索:search → read → 汇总结论 + 引用(citations 必须来自 show_citations 的工具结果)。
| domains | 仅 knowledge / doc_library / system,传入写域直接 400 |
| 6 步 / 90s | maxSteps 与硬超时;超时返回 tool_timeout 而不是挂死主循环 |
| depth ≤ 2 | 默认 maxDepth=1 允许一层再委派,硬上限 2;超限返回 subagent_depth_exceeded |
| 提示词分叉 | depth < maxDepth 时才告知「可以再拆」;到顶时明令禁止再 spawn |
| 贯通取消 | 主请求 AbortSignal 透传;用户点停止时子代理一并中断,errorCode=aborted |
并行扇出 spawn_research_fanout
多路独立子问题各派一个研究子代理,Promise.all 并行后聚合。
| ≤ 3 路 | tasks 数量上限,zod 层直接锁死 |
| depth=0 / maxDepth=0 | fanout 工人强制不许再嵌套——防「并行 × 深度」组合爆炸 |
| usage 聚合 | 汇总 calls / totalTokens / succeeded / failed,主助手可见成本 |
| 部分成功 | 有一路成功即 ok;全挂才报 fanout_all_failed |
写规划子代理 spawn_write_subagent
「写意图」先规划后执行:子代理只读检索核实目标,通过独占工具
propose_write_actions 提交提案,自己碰不到数据。
| 提案白名单 | 7 个工具可提:create_article / update_article / create_article_share / move_article(write)与 delete_article / revoke_article_share / delete_document(dangerous) |
| ≤ 8 条 · 去重 | 按 toolName + JSON(input) 去重,白名单外的提案直接丢弃并计数 rejected |
| risk 标注 | 每条提案带 risk;dangerous 项主助手必须转确认闸门,不得直接执行 |
| 只读三域 | 装载 knowledge / doc_library / system,另外挂上独占的 propose 工具 |
| 6 步 / 90s | 与研究子代理同规格;无方案且无提案时报 empty_write_plan |
06 · 护栏与落档
危险操作、失败降级与全量回放
三层防线的共同思路:不指望模型「自觉」,而是让危险路径在结构上走不通。
确认闸门:删除类操作的七步旅程 confirmation.ts · confirmation-store.ts
- 注册表先扣牌:装配工具时
risk=dangerous的工具直接跳过——模型的工具列表里根本没有 delete_*。就算硬调,注册表里的 execute 也会先撞directDangerousGuard。 - 模型只能出卡:想删东西只能调
request_user_confirmation,把目标工具名与参数放进 action;toolName 必须命中 7 个白名单(delete_article、revoke_article_share、delete_document、delete_ai_config、update_ai_config_credentials、revoke_agent_api_key、set_public_qa_enabled)。 - 服务端签票:校验通过后往
petrichor_assistant_confirmation写一张 pending 票据,存的是服务端认定的 toolName 与 input;同 key 重提则覆盖。 - 前端渲染确认卡:卡片是流里的一个工具 part,带 title / description / destructive 样式,以及「本会话允许同类操作」勾选框。
- 用户点确认:前端把
{ confirmed: true, confirmationId, allowForThread? }写回该工具 part 的 result,发起下一轮请求。 - 运行时代执行:新一轮请求进来,运行时扫描消息找到「confirmed 且没有 executionOutcome」的卡,原子消费票据(update … where status='pending' returning),再次校验白名单与注册表后由运行时亲自执行——客户端传的 toolName / input 一律不信。
- 结果写回:executionOutcome 被 patch 回那条工具消息并记为 step 0,模型看到真实结果后才继续叙述,杜绝「假装已删除」。
会话 allowlist confirmation-allowlist.ts
「每次都点确认」很烦,但「一次授权全会话」很危险,于是折中:允许放行的只有非破坏性的那一档。
| 存放位置 | thread.danger_allowlist_json,形如 { toolNames[], updatedAt } |
| 24h TTL | 过期即清空并按未授权处理(DANGER_ALLOWLIST_TTL_MS) |
| 永不入册 | delete_article、delete_document、delete_ai_config、revoke_article_share、revoke_agent_api_key、set_public_qa_enabled——6 个 critical 工具每次都要卡 |
| 实际可放行 | 7 个危险工具里只剩 update_ai_config_credentials 能进 allowlist |
| 命中效果 | request_user_confirmation 同轮直接执行,返回体带 autoApproved: true 与 executionOutcome,不再出卡 |
失败降级 playbook tool-resilience.ts
工具失败不抛异常,而是返回结构化软失败喂给模型,按同名连败次数升级:
| 30s | 单次工具硬超时(Promise.race + AbortController),超时算一次失败 |
| 首败 | tool_degraded · retry_other_tool:「换工具、调参数,或直接回答」 |
| 再败 | tool_degraded · answer_without_tool:「停止依赖该工具,基于已有信息回答」 |
| 连败 2 次 | tool_circuit_open 熔断:后续调用直接短路返回,提示词明令不得再调 |
| per-run | failStreak 随 run 生灭,不跨请求记仇;成功一次即清零 |
全量落档 thread-logic.ts
每一步都可回放,也是后续调优提示词与工具的原始数据:
| thread | 会话 + 上下文摘要与折叠水位 + 危险 allowlist + 操作员记忆快照 |
| run | 一次请求一条:intentDomains(LLM 改判后回写)、模型配置、COMPLETED / FAILED 与 errorCode |
| step | 每次工具调用:名称(子代理带 spawn_*/d1 前缀)、输入(敏感键 [redacted])、输出、errorCode、耗时 ms |
| message | 用户消息进门即存;编辑重提时先按 keepCount 截断旧历史再写入 |
| 助手消息 | 流结束时剥掉压缩状态 part 与 warning 级步数条、去重意图 part 后落库,并带 usage / 总耗时 / tokens 每秒 |
| embedding | 折叠区消息的脱敏摘录 + pgvector 向量,唯一键 message_id |
| 响应头 | X-Petrichor-Assistant-Thread-Id / Run-Id,前端与日志可互相对账 |
运行时的设计边界(known limits)
- 规则路由是纯正则启发式——省钱且可测,但改写措辞可能路由不中;这也是为什么低置信度要交给 LLM 复核,而不是把正则堆得更长。
- 意图 LLM 只影响芯片与 admin 装载:核心四域已常驻,路由判错的代价被结构性地压小了。
- token 估算是「字符数 ÷ 2」的粗估,只用于触发阈值,不做精确计费。
- 熔断状态 per-run 隔离:同一会话下一条消息会重新给失败工具机会,这是刻意选择而非遗漏。
- 危险操作白名单与域/工具形状由 roadmap 契约锁定(
chat-first-universal-agent4.2–4.4),改动需先过 roadmap update。
07 · 全站星图(本次新增)
让 Agent 自己画一张全站概念地图,再拿它检索
全文检索回答「哪篇文章提到了 X」,但回答不了「A 和 B 有什么关系」。
于是加了第二张地图:由抽取 Agent 从已公开分享的文章里归纳出概念、实体、关系,
校验通过后发布成前台 /graph 的可交互点群,同一份载荷也成了助手与前台问答的检索入口。
代码在 src/server/site-graph/。
取公开文章
复用前台 loadPublicSiteArticles 的可见性判定(已排除撤销 / 过期 / 加密 / 不可索引),不另写一套规则。
确定性骨架
root → 知识库分类 → 文章,外加固定的「概念」「标签」两个分类。不依赖模型——模型全挂也有可用图谱。
分批抽取
每 5 篇一批送模型,单篇正文截到 2600 字;每批 ≤12 节点 / ≤20 关系。任一批失败只记 warning 并继续。
实体对齐
名称与别名建倒排索引:规范化后精确命中即自动合并;仅名称相近的落成待确认候选,交后台人工拍板。
校验 → 发布
结构 / 引用 / 成环 / 孤儿 / 属性合法性检查;存在 error 级问题拒绝发布。新节点一律 DRAFT。
前台与检索
公开载荷每次读取都按当前公开文章集重新过滤,供 /graph 点群与 search_knowledge_graph 共用。
抽取 Agent extract-agent.ts
| 批大小 5 | SITE_GRAPH_BATCH_SIZE;再大容易超上下文并降低抽取质量 |
| 2600 字 | 单篇送模型的正文上限,超出截断并标注 [内容已截断] |
| 回喂已知实体 | 把注册表里权重最高的 60 条实体清单写进提示词,要求「涉及就复用它的 nodeKey」,从源头减少同义分裂 |
| 只收两类节点 | 模型只能产 concept / entity;回写文章/分类节点一律忽略(骨架已生成) |
| 关系两端必须已知 | fromKey/toKey 不在本批文章键或本批新节点里 → 直接丢弃并计一条 warning,这是拦幻觉的第一道闸 |
| 跨批累积 | 上一批抽出的实体立刻成为下一批的对齐基准;跨次运行则从库里 loadEntityRegistryEntries 继承 |
实体注册表 entity-registry.ts
解决「同一事物在不同文章里被起了不同名字」。分两档:精确档自动合并,模糊档只出候选。
| 归一化 | NFKC 折全角 → 小写 → 去掉全部非字母数字。于是 Vector Search / vector-search / 全角写法三者等价 |
| 精确命中 | 键 / 名称 / 任一别名命中倒排索引 → 直接复用已有规范键,原名降级成别名 |
| 相似度 | 二元组 Dice 系数;短串(≤6)另算字符级 Dice × 0.9 取大者,压制字序颠倒误判 |
| 候选阈值 | 相似度 ≥ 0.62,或互为子串且较短一方 ≥ 3 字(避免「AI」命中一切) |
| 候选上限 | 单次运行最多 100 条候选,落 merge_candidate 表等人工确认,绝不自动合并 |
不引图数据库 graph-query.ts
层级走邻接表,跨树关系单独一张表,遍历交给 PostgreSQL 递归 CTE:
| 层级 | site_graph_node.parent_id 自引用;structure 连线由它派生,不入关系表 |
| 子树 | with recursive 向下展开,depth 上界同时是递归终止条件——人工改出环也不会无限递归 |
| N 跳邻域 | 在关系表上无向展开,hops 1–3;递归项用 union 去重 |
| 祖先链 | 沿 parent_id 上溯,供后台面包屑 |
| SQLite 兜底 | 本地 drizzle sqlite driver 无 execute(),每个查询都配了等价内存遍历(图上限 1200 节点,成本可忽略) |
校验与可见性 validate.ts · payload.ts
| error 级 | 重复键 / 缺根 / 空名 / 外链 route / 断父 / 文章缺 articleId / 父子成环 / 断边 / 自环 / 空关系名 —— 有一条就拒绝发布 |
| warning 级 | 超 1200 节点或 2400 关系、层级超 6、孤立节点、文章已不公开(发布时自动归档,不再卡发布) |
| 评分 | 100 - error×20 - warning×5,issues 截 200 条 |
| 每次读都过滤 | 「当时可公开」≠「现在仍可公开」:前台载荷按当前公开文章集重新过滤,标题与链接也用最新值覆盖 |
| 摘掉悬空概念 | 从文章节点出发沿非结构边 BFS,捞不到的概念/实体/标签一并摘掉,再清掉空分类——用度数判断会永远为真,因为所有概念都挂在「概念」分类下 |
| 缓存 | cachePublicContent("siteGraph"),TTL 1 天;生成 / 发布 / 下线 / 合并 / 清空都主动失效 |
图谱增强检索 qa-retrieval.ts
助手侧新工具 search_knowledge_graph,前台问答共用同一函数、同一渲染:
| 入口打分 | 名称精确 100 / 别名精确 95 / 名称互含 60×长度比+20 / 别名互含 55 / 属性命中 40 / 摘要命中 30 |
| 切词再打分 | 中文整句几乎不可能等于概念名,所以按标点切成候选词逐个打分,取每节点最高分 |
| 骨架不当入口 | root / section 命中没有信息量,直接跳过 |
| 扩散 | 限跳 BFS,maxHops 默认 2(1–3),命中入口 ≤5(1–10),节点上限 60、链路上限 12 |
| 结构边不扩散 | 否则「同属一个分类」会被当成语义关联 |
| 链路优先通文章 | 先回溯通向 article 的路径(可引用、可继续深挖),再补概念链路 |
| hop 0 的文章 | 问句直接命中文章标题时不产生链路,单独收进 articles,避免漏掉最相关那篇 |
| 空结果 | 返回 emptyMessage 明确指引改用全文检索,而不是静默返回空数组 |
两处细节,都是踩过的坑
| 只读入口拆分 | 助手每次请求都在这条路径上,原先经 service.ts 会连带加载抽取 agent 与 AI generation 链——于是把只读入口单独拆出 public-graph.ts |
| 不重查库 | 直接消费缓存后的公开载荷:可见性规则只实现一次,重查一遍等于把规则再写一遍,极易漏 |
| 星图不能替代全文检索 | 星图只覆盖已公开分享的文章,查不到私有知识库。系统提示词里写死:图谱未命中或涉及私有内容时,一律回到 search_knowledge |
| 前台渲染 | d3-force-3d 力导(固定 2 维)+ Canvas 2D + HTML 标签层;助手卡片里的迷你点群复用同一 runtime,子图无结构边时回退用关系边做布局力 |
| 人工优先 | locked 的节点/关系 Agent 重跑时跳过;source=MANUAL 的数据不会被清理 |
| 归档而非删除 | 文章下架 → 节点归档;重新公开时下一次生成会把它放回草稿 |
08 · 操作员记忆体系
只给操作员的三层记忆,其余用户完全无感
唯一门闩 isAssistantOperator(当前 = 超级管理员,收口在 operator-gate.ts,本栈禁止散落
isSuperAdmin)。非操作员:系统提示里没有记忆段、工具列表里没有 memory 工具,写路径直接
403 assistant_operator_only——三层记忆对普通用户零暴露。
常驻短文记忆 operator-memory.ts
petrichor_assistant_operator_profile · 按 user_id 一行
仿 Hermes 的 USER.md + MEMORY.md:两块 Markdown 短文,每轮拼在系统提示最前,标题锁死为「操作员常驻记忆(本线程冻结快照)」。工具
memory_manage 增删改,只写持久 profile。
| user_profile_md | 类 USER.md 的用户画像,≤ 1375 字 |
| agent_notes_md | 类 MEMORY.md 的约定与笔记,≤ 2200 字 |
| 合计 ≤ 3575 | 超上限拒写 memory_limit_exceeded,库内容不变 |
| add / replace / remove | replace、remove 靠子串命中;未命中 → invalid_patch |
线程冻结快照 thread.operator_memory_snapshot_json
{ userProfileMd, agentNotesMd, frozenAt }
关键设计:记忆写入只影响新线程。新线程首轮把当时 profile 固化成本线程快照,之后只读;同线程内
memory_manage 改的是持久 profile,本线程快照纹丝不动。幂等:已有快照不再回写覆盖。
持久 profile
operator_profile 表
memory_manage 实时改这里
线程 A · 快照
frozenAt = T1
首轮固化即冻结,后续再问仍读旧内容
线程 B · 快照
frozenAt = T2
新开线程首轮才看到 T1→T2 的改动
跨线程情景检索 operator-episodic.ts
petrichor_assistant_message_embedding · 折叠区脱敏摘录
想回忆「上次那件事怎么做的」时,模型主动调 search_operator_history 跨自己所有线程检索——不是把整段历史塞进上下文,而是按需捞片段。数据源正是第 03 节折叠时脱敏落库的那批向量。
| keyword | Postgres 全文 to_tsvector / plainto_tsquery(simple 配置)+ ts_rank 排序 |
| semantic | pgvector 余弦,需用户配了嵌入模型;没配静默跳过 |
| both(默认) | 两路并行 + 按 threadId:messageId 去重合并(语义结果优先),semantic 阈值 ≥ 0.25 |
| excludeCurrentThread | 默认排除当前线程;limit ≤ 20;SQLite 环境回退 ilike |
| 二次脱敏 | 取出来的摘录再过一遍 sanitizeRecallExcerpt,历史脏数据也不会漏 |
记忆的设计边界(known limits)
- 记忆按 user_id 隔离,多个超管互不共享;门闩当前等价于
isSuperAdmin。 - 写入永不改本线程冻结快照——「同一线程里看不到刚写的记忆」是刻意行为,不是 bug。
- 情景检索的 semantic 分支依赖用户配了嵌入模型;没配则只剩关键词 FTS,SQLite 环境进一步退化为 ilike。
- 常驻短文有硬字数上限(1375 / 2200 / 合计 3575),塞不下就得先删旧的。
- 原第 4 层「可写 Skills 与手动进化」已整体移除:三节面板上线以来零数据,连同后端、API 路由与两张表一并删除。
operator_skill表与读路径保留但只读不写,行为等价于「只有内置 skill」,便于日后恢复。