← 返回 Tony's Lab
Teardown · DeepSeek Harness 插件

DS Bot 内部拆解:一个会话编排层是怎么搭起来的

它没有改 DSH 一行内核,也没有新增一种事件类型。整支「Bot 团队」——主 Bot、Bot 间消息、群聊轮次、长期记忆、上下文续命——全部叠在普通的 Session 之上。这篇拆开看它到底怎么做到的。

发布于 2026-10-11 · 源码版本 v0.1.0(DSH 0.2.1-alpha.1)
Bot A · chat root Session group Session hidden, per member deliver() / 事件总线 turn/start → turn/end state.json + 交换日志 Bot B · chat reply 路由回去 [Group chat …] 轮次 / @点名 / [PASS]
消息不以函数调用返回,而以「一个新的用户消息」灌进对方 Session —— 蓝=请求/回复路由,青=组内轮次
21宿主模块
1状态文件
FTS5聊天索引
0新增事件类型
0.8默认 check line
Apache-2.0许可证
骨架

01一切都是一次普通的 Session

插件的入口 index.js 极短:定义一个 21 项的 MODULES 数组,逐个调用 module.install(rt),把函数挂到一个共享的运行时对象 rt 上。模块之间不直接 import 互相调用,而是运行时通过 rt 取用——于是注册顺序就是行为顺序。

// index.js
export const inject = ['llm','tools','systemPrompt','sessionController',
  'sessions','sessionTitle','workspaceRegistry','connection']

const MODULES = [diagnostics, store, models, turns, delivery, roster, agents,
  prompt, tools, chatIndex, groups, memory, parts, images, secrets,
  connectors, schedules, browser, usage, update, channel]

顺序被刻意安排:usage 排在所有会改写请求的监听器之后,这样它计量的 token 是最终真正发出去的请求。这类「顺序即语义」的取舍,是理解这套代码的钥匙。

▸Bot 是怎么被「造」出来的

▸系统提示词为什么是「一段」

整个身份提示只注册一个 section(order: 640,interpolate: false),文本只依赖渲染它的那个 Session 自己的 Bot 记录。因为前缀逐字节稳定,provider 的 prompt cache 才能命中;名册怎么变、别的 Bot 怎么改,都不影响这段前缀。

更细的一步:群聊里成员用的隐藏 Session,渲染的提示词与它私聊时完全相同。于是私聊和群聊共享同一份缓存前缀,群聊回合也绝不会读到该成员的私人上下文。

生命周期

02一条消息的一生

DSH 的 Session 之间没有「同步调用」这回事。deliver() 做的就是把一段文本当成一条新的用户消息灌进对方 Session:

const agent = await ctx.sessionController.resolveAgent(sessionId)
agent.followup(createUserMessage({
  content: [{ type: 'text', text: `[Message from ${fromName}]\n${text}` }],
  source: { kind: SOURCE_KIND, form: 'relay', role, exchangeId, hop, replyTo },
}))
await ctx.sessions.flush(agent.session)

身份不是靠 API 参数传递的,而是靠消息头 + 一个结构化 source 对象双重编码:

消息头含义
[Message from X]发给这个 Bot 的请求,回复会回到 X
[Message from X · the user sees your reply here]X 让它在自己的聊天里回答用户,回复不回传
[Reply from X]另一个 Bot 的异步回复到了
[Group chat "R" · new message]群聊回合(隐藏会话)

▸回复怎么被路由回去

turns.js 订阅 session/event,用一张进程内的 live 表跟踪每个 Session 的当前回合:

一个很容易忽略的健壮性细节:如果接收方的模型调用失败了,它会给发送方补一条明确的失败通知(could not answer … its model call failed)。否则发送方会永远等一个不会来的回复。而 AUTH 类失败的消息内容会被清空——因为 provider 有时会把 key 的一部分回显在错误里。

▸「隐藏回合」与吉祥物状态

不是所有回合都该让用户看见。被 Bot 间消息触发的那些,会在 state.exchangeTurns[sessionId] 里打标,界面据此隐藏;群聊会话整体隐藏,所以无需打标。吉祥物「在干什么」则由实时的 tool/call 事件映射而来(sending / orbit / searching / working / thinking),只在进程内重建、从不落盘。

上下文

03上下文管家:不用内核压缩,自己决定怎么续命

DSH 自带的压缩在 Bot 会话上被完全禁用。取而代之的是一条按模型窗口算出来的水位线:

可用上下文 · 正常工作
check → hard
回复预留
0check = checkRatio × usable(默认 0.8)hard = 95%窗口
// steward.js —— 只回一行 JSON,取「最后一个」带 action 的对象
{"action":"switch"|"compact", "reason":"一句话"}
//  switch  → 工作到了一个停顿点:换一段全新 Session
//  compact → 还在进行中:就地压缩旧消息,无缝继续

compact → 就地 checkpoint

旧消息被换成一条带编号的 [Checkpoint N]:管家写的摘要 +「发给你的消息,逐字保留」。用户最近的原话逐字留在摘要旁——借鉴 Codex 的做法。

switch → 交接笔记 + 新段

空闲时用 同一段前缀让 Bot 自己写交接笔记(于是由缓存提供、不额外等待);新段以一个「交接包」开场:笔记 + 最新消息逐字 + 其余内容在哪。

无论哪种,用户看到的始终是一个连续聊天、没有分隔标记。而「压缩了几次」会被真的数出来:管家提示里会告诉它「这段已压缩 N 次」,越压越倾向于把合理的停顿点算作终点——因为「摘要的摘要会丢更多细节」。越界溢出(超过 hard line 甚至窗口)时,直接换段、连笔记都来不及写。

被换掉的内容并没有消失:每个 part 都写进本地全文索引,Bot 用 read_own_chat 带着词或 part N 就能翻回去,返回的行都带锚点,例如 [part 2 #1234 · 10-08 14:22],可再按锚点读上下文。

协作

04群聊轮次协议:一次只让一个说话

群聊是「一个共享的、有序的对话」。核心约束是:一个成员的回合只有在上一个发言者发完或弃权之后才开始。它会看到自上次看过以来新增的内容、当前发言顺序、以及自己在其中的位置——谁都不用数行数去猜轮到谁。

机制实现
回合/增量群日志带 seq;room.seen[memberId] 记每人读到哪,只补增量(上限 24 行,首次 12 行)
沉默回复 [PASS] 即弃权;正则同时认 【pass】、pass. 等变体
点名@名字 触发下一轮;按名字长度降序匹配,所以 @Mx 不会被误读成 @M;只有 ASCII 字母数字能延续一个名字
@全体@all / @everyone / 所有人 / 全体
两种模式admin(默认):普通消息先给管理员,由它 @ 点名;everyone:每人按名册顺序、管理员优先依次作答
防死循环每个成员每轮调用次数有上限(普通 2 / 管理员 4),额外的「接力」也有上限(4 / 4);自用户上次发言后,Bot 最多只能主动发起 N 轮

模型很爱模仿转写格式,把名字前缀也吐出来。所以有一条 stripOwnName():发送前老老实实把这行 Name: … 前缀剥掉。

▸为什么群聊费钱,但不会读到你私聊

每个成员在每个群里都有一个专属的隐藏 Session(room.sessions[botId]),它与该成员私聊渲染同一套提示词和工具。这带来两个后果:前缀缓存共享;群聊会话与私聊会话互相隔离——群里读不到成员的私人聊天。这个隐藏 Session 的第一条消息是一份该成员私聊的「简报」,因此固定不变、可缓存。

进群发言前,成员可以 read_local_chat 主动看自己的私聊(群会看到「它去查了」这一行),但只分享群需要的内容;在私聊里则用 read_group_chat 看群里说了什么。

群会话本身只是个「中继」:它的系统提示告诉它你不是参与者——对每条新用户消息调用一次 group_relay,然后以空回复结束回合,绝不自己加话。

记忆

05记忆:写死的格式 + 每轮自动复盘 + 提示词冻结

记忆是纯 Markdown,格式取自 Agent Memory Repo:每行一条、结尾带 [source: …; added: …] 元数据;MEMORY.md 用 ## Index 把主题文件以 [[topic]] 链出去。

~/.dsh/bot/memory/
  bots/<botId>/    某个 Bot 自己的 MEMORY.md 与主题
  team/            全队共用、每个 Bot 都读
  journal.jsonl    每一次改动,含改前/改后整行
  snapshots/       每个 Session 冻结下来的那一段
  summaries/       记忆面板的摘要(可删)
  archive/         已删除 Bot 的记忆
  reviewed.json    复盘读到哪了

▸关键:记忆不靠 Bot「想起来要记」

每回合结束后有一次 memory review:它读这段对话里新增的内容(≤16k 字符),自己决定保留、更新还是删除条目。这才是记忆真正可靠的原因——不依赖模型主动调用 remember。三个工具 remember / forget / recall 走的都是同一条写入路径,谁能在哪个作用域写、条目大小上限、拒绝记录密钥,全在这一层强制。

与前缀缓存同样的手法:每个 Session 在首次渲染时冻结自己那一份记忆 section,于是提示词逐字节稳定、缓存命中;改动从「下一段或下一个 checkpoint」才可见(而 recall 是实时的)。变更历史靠 journal.jsonl 的改前/改后,不需要 git。

安全

06密钥:Bot 永远看不到明文

Bot 要 key 的时候调 request_secret,用户在自己聊天里往一张卡上填。值只存在于 secrets.json(权限 600)和宿主进程内存中,Bot 通过环境变量 $DSH_SECRET_<NAME> 使用,从不经过模型。

▸遮蔽器 redactor()

作者把局限也写进了文档:只替换「完全一致的副本」,Bot 若把值编码、拆分、倒序仍可能打出来;Windows 下 600 权限位无效;非 HTTPS 页面传输是明文。结论写得很直白:别把重要密钥交给你不信任的 Bot。

持久化

07一个状态文件,一把单写者锁

state.json 装着整个团队:版本、workspace、主 Bot 列表、bots、rooms、agents、交换日志、隐藏回合表、待答问题、偏好、revision。做法很朴素但很稳:

// 原子写:先写临时文件,再 rename
const temp = `${statePath}.${process.pid}.tmp`
await writeFile(temp, snapshot, 'utf8')
await rename(temp, statePath)   // 同一台机器上 rename 是原子的

写入被串到一个 promise 链上,避免并发覆盖;adopt() 里则集中处理所有历史数据迁移(旧的 lab 字段 → 各自的模型、单个 mainBotId → 数组、初代鲸鱼头像、旧主题名、群聊的 admin/mode…)。

▸桌面版和 Web 版同时开怎么办

两者默认共用 ~/.dsh,若同时内存里各存一份团队状态、各自整文件写回,就会互相覆盖。于是有一把 state.lock:一个很小的 JSON(pid、host、token),持有者每 15 秒 bump 一次 mtime,60 秒没 bump 就算过期。

工程细节

08几个做得挺讲究的角落

聊天索引用 trigram 分词

SQLite FTS5,tokenize='trigram'——中文因此不需要分词;短于 3 字符的词退回 LIKE。BM25 排序。派生自 Session 日志,文件删了重建即可。

用量账本两个来源

有日志的调用读日志(所以插件诞生前的调用也算数);没有日志的调用(checkpoint、交接、管家、读图、DSH 标题)边流边记。按「一刻钟」聚合——任何时区偏移都能整除 15 分钟。

图片:读一次,缓存进 prompt

给不能读图的模型,先用视觉模型把图转文字,按附件 id 缓存(ocr.json)。于是后续请求发送的是同样的文字,前缀缓存仍能命中——而不是每次换一段描述。

桌面版浏览器走长轮询

页面在 Electron 的 webview 里、不在宿主进程。read_browser 靠客户端持有的长轮询通道(browser-wait / browser-result)把读取请求送进去,2 秒重发,客户端 30 秒不轮询即视为离线。

定时任务跟着段走

用的是 DSH 自带的 schedule 服务(可选 bundle)。Bot 的聊天换段时,先把任务在新段上重建、再删旧的——失败就留在原处,不会丢。

DSH Agent 要屏蔽团队工具

侧边栏那个「原生 DSH 入口」只该看到原生能力。代码用 tools.restrict({deny}),并在 schema 未就绪时每 5 秒重试直到生效——避免它短暂拿到团队工具。

选模型要写回用户默认值

DSH 的 selectModel 会把选择顺带存成 profile 默认。Bot 的模型是它自己的,所以 DS Bot 在之后把用户的默认值写回去,并把选择串行化,保证 profile 写入按提交顺序落盘。

一个团队只有一个 DSH Agent

创建失败会回滚:把已建好的 Session 归档、删掉记录并持久化,否则 Agent 会在重启后带着「没被屏蔽工具」的状态复活。

结论

09它到底好不好

这套实现最值得学的不是功能清单,而是三个反复出现的同一招:

但它是 v0.1、只测过 DSH 0.2.1-alpha.1。底座是 alpha,新版本可能直接弄坏插件;密钥明文落盘、群聊烧 token、跨机迁移未验证。现在适合拆着学、别托付关键活。

源码:github.com/FeiZhuLulu/DeepSeek-Bot(Apache-2.0,非 DeepSeek 官方)。本文基于 v0.1.0 源码,附带 git 版本 v0.1.0。