幽灵引导——一条建议消息,弯转对话方向
父节点:agent-core · 状态:已构建,并在真实模型上验证过(2026-07-16)。策略层、waypoint 冻结和已访问台账都已存在且能工作;waypoint 按角色(role)和按 code 分别著作(code 继承其角色的 waypoint,可按
waypoint_id覆盖)。面向 owner 的编辑器后来也落地了(role-waypoints-admine2e),所以下文那条元原则——"owner 只写目的地"——现在有可供书写的界面了。
Owner 希望访客对话向按角色设定的目的地收敛(查看某个项目、理解差异化、预约一次通话)——但议程是访客自己的,不存在硬控制。唯一可用的工具是一条建议通道:幽灵消息(ghost message)。这是 steering-without-control 的一个实例:一个场,而不是一道围栏。
四根理论支柱
- 选择架构 / 助推(Thaler–Sunstein)。 不约束选项集合,而是塑造默认项。单条幽灵消息是最强的形式:为下一步动作提供一个纯粹的默认值。(Hick 定律 / 选择过载:菜单会稀释注意力,默认项才促成转化。这正是一条幽灵胜过三条的原因——界面直觉和理论在此一致。)
- 带建议偏置的 MDP。 对话 = 状态(已覆盖的话题、已展示的证据、已达成的目标);访客的下一条消息 = 动作。幽灵消息并不改变动作空间——它偏置转移核(被提供的文本获得更高的接受概率)。Owner 的目的地被编译成一个奖励
R;幽灵策略挑选让向R推进的期望值最大化的候选项。 - 目标导向对话(Target-guided dialogue,Tang 等人 2019,以及主动式对话这一研究线)。已发表文献中的核心张力是:局部连贯性 × 全局进展——不连贯的跳转是文献记录在案的失败模式。下文机械化的"挂钩"规则正是为此而设。
- 管道 / 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 序列直接驱动,不需要新的基础设施。
类图
带 ghost 的单轮流程(时序图)
Waypoint 与 ghost 的生命周期(状态图)
闭环(否则它只是空中楼阁)
- 离线(已建成):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;e2eghost-telemetry.spec.ts/ghost-telemetry-admin.spec.ts。访客真正想要的是哪些目的地:这是给 owner 关于自己叙事拉力的真实反馈(这本身就是一个产品时刻); - 每个对话的 waypoint 漏斗;到达终点所需轮数;游荡轮数——未建成(
GhostWaypointStat里没有按对话的漏斗); - 沉默率——策略健康度指标:过低 = 在推销,过高 = waypoint 设置得太单薄——未建成(沉默的一轮不写
conversation_ghosts行,所以没有东西在数它)。
- 各 waypoint 的接受率——已建成,2026-07-07(7faadc507):
- UI 上的解锁:单条 ghost 恰好契合 ghost-text 模式(灰色的行内文字,Tab 键接受)——比按钮更安静,与"阅读优先"的立场一致。