幽灵引导——一条建议消息,弯转对话方向

更新于 · 在 sijie.xyz 查看原条目 ↗

父节点:agent-core · 状态:已构建,并在真实模型上验证过(2026-07-16)。策略层、waypoint 冻结和已访问台账都已存在且能工作;waypoint 按角色(role)和按 code 分别著作(code 继承其角色的 waypoint,可按 waypoint_id 覆盖)。面向 owner 的编辑器后来也落地了(role-waypoints-admin e2e),所以下文那条元原则——"owner 只写目的地"——现在有可供书写的界面了。

Owner 希望访客对话向按角色设定的目的地收敛(查看某个项目、理解差异化、预约一次通话)——但议程是访客自己的,不存在硬控制。唯一可用的工具是一条建议通道:幽灵消息(ghost message)。这是 steering-without-control 的一个实例:一个场,而不是一道围栏。

四根理论支柱

  1. 选择架构 / 助推(Thaler–Sunstein)。 不约束选项集合,而是塑造默认项。单条幽灵消息是最强的形式:为下一步动作提供一个纯粹的默认值。(Hick 定律 / 选择过载:菜单会稀释注意力,默认项才促成转化。这正是一条幽灵胜过三条的原因——界面直觉和理论在此一致。)
  2. 带建议偏置的 MDP。 对话 = 状态(已覆盖的话题、已展示的证据、已达成的目标);访客的下一条消息 = 动作。幽灵消息并不改变动作空间——它偏置转移核(被提供的文本获得更高的接受概率)。Owner 的目的地被编译成一个奖励 R;幽灵策略挑选让向 R 推进的期望值最大化的候选项。
  3. 目标导向对话(Target-guided dialogue,Tang 等人 2019,以及主动式对话这一研究线)。已发表文献中的核心张力是:局部连贯性 × 全局进展——不连贯的跳转是文献记录在案的失败模式。下文机械化的"挂钩"规则正是为此而设。
  4. 管道 / waypoint 视角(我们自己的提法)。 Owner 声明的waypoint 是期望对话走廊的横截面;幽灵消息是一道软门——它从不阻拦,只是把对话重新拉回中心。用势场的形式表达:φ(state) = 到未访问 waypoint 的加权距离;每条幽灵消息都在连贯性约束下最大化期望的 φ 下降量;一个无视它的访客感受到的力恰好为零。

只留一条幽灵的决定(为什么它更强,而不只是更友好)

  • 默认项胜过菜单——见支柱一。
  • 探索发生在多轮之间:这把 bandit 问题变成了序贯的。一条被拒绝的幽灵消息(被无视 / 访客自己打字)是对该 waypoint 的一个干净的负信号——若是三选一菜单,你分不清"拒绝了 A"还是"只是更偏好 B"。
  • 单一位置迫使出两条规则,使策略读起来像引导而不是推销:
    • 动量(柔道式引导)——如果访客自己的消息已经指向了某个未访问的 waypoint,就顺势推那一个;借力于水流,而不是与之对抗;
    • 沉默也是一种动作——没有自然的挂钩点,或没有可推进的事项 ⟹ 什么都不产生。过度助推会训练出banner盲视和逆反心理;稀缺性才能保住默认项的力量。

元原则:owner 只写目的地,不写机制

系统提示词是平台所有、稳定、带版本的(part_ids + hash——适用 system-prompt-hash-regression 的纪律)。Owner 按角色著作:waypoint(描述、权重、evidence_refs)、语气约束、禁区——像其他一切一样被冻结进 RoleSnapshot(role-snapshot-frozen)。依据:权力分离(owner 擅长声明目标、不擅长写机制提示词——这是隐性知识与可书写内容之间的裂缝),而稳定的机制是做 A/B 测试和回归测试的前提。

提示词骨架(单幽灵版)

You generate at most ONE GHOST MESSAGE: a candidate next message the
VISITOR might send — written in the visitor's voice, not the owner's.

INPUTS per turn:
- role: {persona, tone constraints, forbidden zones}
- waypoints: [{id, description, weight, evidence_refs, visited?}]
- conversation summary + the assistant's LAST message
- ghost history: [{waypoint, offered_at, accepted?}]

RULES:
1. VOICE — something a curious {role} would plausibly say next:
   short, first-person, their register. Never marketing, never pressure.
2. COHERENCE — it must hook onto something concrete in the
   assistant's last message (name what it follows from).
3. HEADING — tag it with exactly ONE unvisited waypoint it advances.
   No tag → do not emit.
4. ONE OR NONE — if no natural hook advances any unvisited waypoint,
   emit NOTHING. Silence is an action.
5. SELECTION — follow the visitor's momentum when their own message
   already points at an unvisited waypoint; otherwise the highest-
   weight waypoint coherently reachable in one hop; bridge if none.
6. EVIDENCE — only propose questions the corpus can answer well
   (waypoints carry evidence_refs; no refs → not proposable).
   <!-- Rule 6 was prompt-only until 2026-07-23 (the real model ignored it: a waypoint with
        evidence_refs=null still produced three ghosts, 2026-07-16). Enforced since a5919835c by the
        `require_ghost_evidence` switch (F-A-10; per-role column, nullable per-code override, frozen
        into RoleSnapshot.RequireGhostEvidence): when on, `conversation.SteeringCandidates`
        (routes/public/agent_turn.go:100) drops non-terminal waypoints with no evidence_refs before the
        policy call; terminals stay proposable. A second floor (F-A-26, usecase/visitor_waypoint_feasible.go)
        drops refs that resolve to no note at freeze. -->
7. NO-REPEAT — a declined waypoint may not be re-offered for N turns.
8. TERMINAL — when booking/contact is one natural step away, the slot
   goes to the ask, plainly.

OUTPUT: {text, target_waypoint, follows_from, is_bridge} | null

逐条依据

条款 依据
voice(语态) 言语行为的归属——点击即等于把这句话据为己有;营销腔调会触发逆反(Brehm)并扼杀接受率
coherence + follows_from(连贯性) 目标导向对话文献记录在案的失败模式;把挂钩点写明使连贯性变得可机械检验(检验会产生梯度信号)
heading tag(标注 waypoint) 与 root-protocol 中"声明你的航向"条款逐字一致:一个说不出自己推进了什么的动作就是在漫游;这个标签同时也是遥测的挂钩点
one-or-none(一条或不产生) 稀缺性才能保住默认项的力量;过度助推 → banner 盲视 + 逆反
momentum(动量) steering-without-control:借力于当前的水流;顺着已显露的兴趣去引导,比硬拽向最高权重转化率更高
evidence gate(证据门) 可行性下限:一条指向语料稀薄之处的幽灵消息会把对话引向失败;是"有据可查、带引用"这一承诺的上游守卫
no-repeat(不重复) 序贯 bandit:一次拒绝就是负奖励;立刻重新提供只会白白消耗信任、换不来任何信息
terminal(终点) 转化漏斗需要一个明确的收口动作;对应对话层面的 force-final-answer
JSON output(JSON 输出) 对结构的机械化约束;target_waypoint 会喂给漏斗统计;follows_from 是审计轨迹

代码设计——它落在现有架构中的哪个位置

今天已经存在的(已验证): ghost 目前已经是一种静态机制——access_codes.ghosts jsonb 为每个 code 保存一份 owner 著作的建议列表,conversation_ghosts 记录每一次展示:{id, owner_id, conversation_id, turn_index, ghost_text, source, shown_at, accepted_at}。接受与否已经是一个时间戳(accepted_at,NULL 表示被拒绝/待定),source 也已经在标注来源。本设计要做的,是把 ghost 从一份静态的按 code 罗列的清单升级为一套策略——source 在原有静态值之外新增一个 policy 值。

数据(沿用已有的 role-join 模式——全部已在 backend/db/schema.sql 里):

  • role_waypoints——挨着 role_corpus_uris/role_skills 的连接表:{role_id, waypoint_id, description, weight, evidence_refs jsonb, is_terminal}(schema.sql:508);在管理后台的 Roles 部分著作。它的 per-code 镜像 code_waypoints(schema.sql:498)由 access.MergeWaypoints 叠在它之上(同 waypoint_id → code 覆盖,新 id → 追加),叠加发生在语料过滤之前。
  • conversation_ghosts 带有 {target_waypoint, follows_from}(schema.sql:1105)——即航向标签和连贯性挂钩,搭载在已有的接受度机制之上;策略产出的 ghost 是 source='policy'。
  • RoleSnapshot 带有 Waypoints + RequireGhostEvidence(internal/access/entity/role_snapshot.go:94,挨着它已有的那些被冻结字段——它已经在冻结 codePromptBody 和 dockButtons,所以这个模式是现成的)——并且在冻结时过滤:一个 evidence_refs 落在该角色被授予的语料 glob(AllowsCorpus)之外的 waypoint,会在门口就被剔除(access.FilterWaypointsByCorpus,entity/waypoint.go:143)。可行性在结构层面被强制:一个角色永远不会被引导向它看不到的证据。

"已访问"标记是纯机械的(α≈0——台账里没有 LLM 裁判):

  • 证据类 waypoint 在 assistant 的引用触及其 evidence_refs 时翻转为已访问(每轮的 grounding 本就已被追踪);
  • 动作类 waypoint 在工具事件上翻转(例如来自预约插件的预约成功)。 waypoint 台账搭载在 Redis 的 visitor_session 上(VisitedWaypoints;internal/conversation/usecase/waypoint_ledger.go)。

策略调用——一个持久化之后的钩子: 生成 ghost 需要拿到完整的回复,所以它在完成后持久化(persist-at-completion)之后、作为本轮的尾声运行(done = 已提交,detached-turn-persist-at-completion;inference.EpilogueFunc,在 routes/public/agent_turn.go:64 buildGhostForTurn 接入——只在 code 模式 + 冻结了 waypoint 时):SteeringCandidates → 用 conversation.GhostPolicyPrompt 调 inference.Generate → ParseGhost → 落库 → 出帧。已建成的版本跑在会话自己的凭据/模型上(agent_turn.go:106),并没有单独的廉价档位——"廉价/快速档位"仍是尚未实现的意图;其机制提示词是一个带 part_ids + hash 的带版本片段(usecase/ghost_policy.go:29;适用 system-prompt-hash-regression——引导的漂移就是提示词的漂移)。未访问集合为空时在任何 LLM 调用之前就短路。

传输与接受检测: 一种 SSE 帧类型 ghost,在 done 之后发出(就是 inference/agent_epilogue.go 里通用的 EpilogueFrame{Kind:"ghost"}——循环只认识 Kind;unbuffered-sse-passthrough 像传输其他帧一样传输它);前端把 ghost 文本渲染进输入框(Tab 键 / 点击接受)。接受 ⟹ 发送的消息携带 ghost_id → accepted=true;任何其他用户消息 ⟹ 待定的 ghost 翻转为 accepted=false(不重复窗口开始计时)。

评测接口: GhostPolicy 是一个作用于 (snapshot, transcript, history) 的纯函数——可以被 eval-harness 用预设好的对话记录和标准 waypoint 序列直接驱动,不需要新的基础设施。

类图

classDiagram
  class Waypoint {
    <<role_waypoints / code_waypoints row>>
    waypoint_id, description string
    weight int
    evidence_refs jsonb
    is_terminal bool
  }
  class RoleSnapshot {
    <<existing - frozen waypoints + evidence switch>>
    -waypoints []Waypoint - ACL-filtered at freeze
    -requireGhostEvidence bool
    -corpusURIs, allowedTools, deniedCapabilities []string
    -promptBody, codePromptBody string
    +AllowsCorpus(uri) bool
  }
  class WaypointLedger {
    <<redis visitor_session - usecase/waypoint_ledger.go>>
    visited map of waypoint_id to turn
    +markByCitation(evidenceRefs)
    +markByToolEvent(event)
  }
  class GhostPolicy {
    <<turn epilogue - on the session's own model>>
    takes snapshot, summary, lastAssistantMsg, history
    returns *Ghost or nil
  }
  class Ghost {
    text string
    target_waypoint string
    follows_from string
    is_bridge bool
  }
  class conversation_ghosts {
    <<existing postgres table>>
    id, owner_id, conversation_id uuid
    turn_index int
    ghost_text text
    source text - static or policy
    shown_at timestamptz
    accepted_at timestamptz NULL
    target_waypoint, follows_from text
  }
  RoleSnapshot o-- Waypoint : frozen copy
  GhostPolicy ..> RoleSnapshot : reads
  GhostPolicy ..> WaypointLedger : reads
  GhostPolicy --> Ghost : at most ONE
  Ghost ..> conversation_ghosts : persisted per showing

带 ghost 的单轮流程(时序图)

sequenceDiagram
  participant V as visitor (frontend)
  participant L as eino loop (backend)
  participant W as waypoint ledger (session)
  participant G as GhostPolicy (session model)
  participant DB as conversation_ghosts
  V->>L: message (carries ghost_id if a ghost was accepted)
  L->>DB: pending ghost → accepted / declined
  L-->>V: stream reply (citations tracked)
  L->>W: citations touch evidence_refs → mark waypoints visited
  Note over L: tool event (e.g. booking ok) → terminal waypoint visited
  L-->>V: done (persist-at-completion)
  L->>G: snapshot + summary + last reply + ghost history
  G-->>DB: {text, target_waypoint, follows_from} or NOTHING
  L-->>V: SSE frame "ghost" → gray ghost-text in the input

Waypoint 与 ghost 的生命周期(状态图)

stateDiagram-v2
  [*] --> Authored : owner edits role
  Authored --> Frozen : code issued (ACL-filtered at freeze)
  Frozen --> Visited : citation hits evidence_refs / tool event
  Frozen --> Offered : ghost targets it
  Offered --> Visited : ghost accepted, turn covers it
  Offered --> Cooldown : declined (no-repeat window)
  Cooldown --> Frozen : window expires
  Visited --> [*] : all terminals hit = destination reached

闭环(否则它只是空中楼阁)

  • 离线(已建成):eval-harness 通过它的 Driver,用预设对话驱动同一个循环;scenarios/ghost-momentum.yml / ghost-silence.yml 带 expect_ghost 标准答案(scenario.go 的 ExpectGhost,Emitted=false 断言沉默)。
  • 在线遥测: 按每条 ghost 记录(是否提供、是否接受、哪个 waypoint)→
    • 各 waypoint 的接受率——已建成,2026-07-07(7faadc507):conversations.ghost_telemetry(usecase/ghosts.go:85 GhostTelemetry → GhostWaypointStat{TargetWaypoint, Shown, Accepted} + AcceptanceRate()),显示在 Conversations 区块的 GhostTelemetryPanel.tsx;e2e ghost-telemetry.spec.ts / ghost-telemetry-admin.spec.ts。访客真正想要的是哪些目的地:这是给 owner 关于自己叙事拉力的真实反馈(这本身就是一个产品时刻);
    • 每个对话的 waypoint 漏斗;到达终点所需轮数;游荡轮数——未建成(GhostWaypointStat 里没有按对话的漏斗);
    • 沉默率——策略健康度指标:过低 = 在推销,过高 = waypoint 设置得太单薄——未建成(沉默的一轮不写 conversation_ghosts 行,所以没有东西在数它)。
  • UI 上的解锁:单条 ghost 恰好契合 ghost-text 模式(灰色的行内文字,Tab 键接受)——比按钮更安静,与"阅读优先"的立场一致。

相关笔记