Post

我要开发一个适合小研的新框架

成品 研/YanXin 一、背景:这件事到底难在哪 把一个 LLM 接到 QQ 上并不难。难的是后面三件事。 第一,身份要连续。她今天在群里说的话,明天还要能接得上;她在自己的世界里做过的事,不能变成另一个她的记忆。这意味着会话 id、工作目录、记忆后端三者必须是确定的,而不是每次重启换一个。 第二

AI写作 阅读 51 点赞 2 评论 0

成品

研/YanXin

一、背景:这件事到底难在哪

把一个 LLM 接到 QQ 上并不难。难的是后面三件事。

第一,身份要连续。她今天在群里说的话,明天还要能接得上;她在自己的世界里做过的事,不能变成另一个她的记忆。这意味着会话 id、工作目录、记忆后端三者必须是确定的,而不是每次重启换一个。

第二,她要能主动。只在被 @ 时回话的机器人是一个查询接口。要有"这段时间她在过日子"的状态,就得有一个时钟、一个闸门,以及一个能自己决定"现在什么都不说"的机制。

第三,失败大多是静默的。她没回话、记忆没写进去、那一拍什么都没发生 —— 日志里一个字都没有。这个项目里我大约一半的时间不是在写功能,而是在把"没发生的事"变成看得见的东西。

下面按"怎么装的 → 一条消息怎么走 → 她怎么主动 → 人格怎么进去 → 记忆怎么落"的顺序讲,最后列坑和局限。

二、三个名词先定义清楚

DSH(DeepSeek Harness) 是一个 agent 运行时:它管 LLM 调用、会话持久化、工具注册、控制台。cordis 是它的插件框架 —— 一个插件声明自己提供哪些服务、注入哪些依赖,框架按依赖图装配。preset 是一组"能力 + 人格"的装配集合:同一个运行时可以有几个 preset,每个 preset 决定这个 agent 能用什么工具、带什么系统提示。

研心本身不是可执行程序,是一个 DSH bundle:47 行装配声明 + 74 个 TypeScript 源文件。跑起来靠 dsh --profile yanxin,入口是编译产物 lib/,不是 src/。

OneBot v11 是 QQ 机器人社区的消息协议。反向 WebSocket 是关键:不是我们的服务去连 QQ 客户端,而是 NapCat(客户端侧的实现)拨进来连我们。这样我们只需要监听一个端口,鉴权靠握手头里的 access token。

ReMe 是外部记忆后端,一个 Python 服务,提供"把一轮对话沉淀成长期记忆"和"按当前问题召回相关记忆"两个动作。它跟 DSH 是两个进程。

三、怎么装配:一张插件表,和它没有的东西

装配写在 cordis.patch.yml 里。节选:


    - id: onebot

      name: 'yanxin/src/onebot/service.ts'

    # bridge:把入站消息变成一次 agent 对话,并把回复发回去

    - id: onebot-bridge

      name: 'yanxin/src/onebot/bridge.ts'

onebot 是传输层,onebot-bridge 是策略层,两行。这里踩过本项目最贵的一类坑:patch 只有 insert 和按 id 整字段覆盖,没有深度合并。

意思是:你想改 config 里的一个键,必须把整段 config 重写一遍;而你把键写到了另一行上(比如把桥的 groupTrigger 写进 onebot 行),系统既不报错也不警告 —— 行 id 对不上就没人读它。症状是"配置明明写着 groupTrigger: name,名字触发就是不生效",一路静默落回代码缺省。

配置因此分三层,越往下越"内容":

层在哪放什么
bundle patch(入库)cordis.patch.yml插件行的结构与开关,不放密钥和部署值
profile patch(不入库)$DSH_HOME/profiles/yanxin/端口、token、账号注册表、工作目录
settings(不入库)$DSH_HOME/settings.yaml运行期可改:管理员名单、时段、模型路由、记忆后端

堵这个坑的不是"以后小心点",是一条守卫:读两个插件自己声明的 config schema,断言每个键挂在认得它的那一行上。这样加新键不用去测试里抄名单,而挂错行立刻红。

四、一条消息的旅程

这是整个系统的主动脉,七步。

① 握手,三重门。 客户端拨进来时,先解析握手头里的 selfId 与 role,再依次过三道:


if (!handshake.ok) return rejectUpgrade(socket, 400, handshake.reason)

if (t.token && !tokenEquals(handshake.token ?? '', t.token)) {

  this.log('warn', 拒绝握手:selfId=${handshake.selfId} 的 access token 不匹配)

  return rejectUpgrade(socket, 401, 'access token 不匹配')

}

if (!this.account(handshake.selfId)) {

  return rejectUpgrade(socket, 403, 未注册的 selfId:${handshake.selfId})

}

token 只在配置了才校验(规范允许 OneBot 侧不发这个头);accounts 是"这个号挂哪套能力"的注册表,未注册一律拒。有个细节:token 每次握手现读,不缓存到字段 —— 泄漏时"立刻换掉"才是这个功能的意义,缓存的结果是"改了要重启才生效"。

② 双向身份校验。 连接声称自己是谁,和事件里自带的 self_id 必须一致:


⚠️ 串号:连接声明 selfId=…,但事件自带 self_id=…,已丢弃该事件

这条告警不是防御性编程,是实测出来的 —— 一个连接确实见过两种身份。

③ 消息归一化。 OneBot 的 message 字段有两种形态:array(消息段数组)和 string(CQ 码,形如 [CQ:image,file=…])。这取决于 NapCat 侧的配置,而那个配置的键名是它自己的,规范里没有,所以不能假设部署用的是哪种,两种都要吃。归一化成同一内部结构后,同时产出两个消费者需要的东西:给模型读的可读文本,和给路由用的结构化事实(谁被 @、有没有图片、回复引用了哪条)。

④ 触发判定。 私聊一律回应;群聊看策略:mention 只回被 @ 的,name 是被 @ 或文本里出现名字都算,never 群里彻底沉默。

⑤ 按会话串行,不同会话并行。


/** 同一 session 串行、不同 session 并行。 */

private enqueue(key: string, task: () => Promise<void>): void {

  const previous = this.queues.get(key) ?? Promise.resolve()

  const next = previous.then(task).catch((error: unknown) => { … })

  this.queues.set(key, next)

}

串台的表现是"回复张冠李戴",这是真实群聊的常态而不是边界情况。

⑥ 拿到 agent:有日志就恢复,没有才新建。


const persisted = await hasPersistedLog(ctx, sessionId)

const handle = persisted

  ? await services.agents.resume({ resumeSessionId: SessionId(sessionId), … })

  : await services.agents.create({ sessionId: SessionId(sessionId), meta: { cwd, agentPreset }, … })

这里有条铁律:不许写"resume 失败就 create"。听起来是稳健的兜底,实际会把 preset 挂载失败、配置写错这类真错误误判成"磁盘上没有日志",于是静默降级成一个新会话 —— 她"忘了这个群",而日志里什么都不报。

⑦ 回复。 一轮跑完后读会话事件,取助手文本,投递回 QQ;然后写回记忆 —— 注意写回是 void memory.record(…),挂在会话队列之外(第七节讲为什么)。

五、她怎么"主动说话"

被动响应不需要时钟,主动行为需要。三层:

窗口调度:默认每天 14:00–18:00,每分钟裁决"现在在不在窗口内",把世界引擎那行启停。这个调度器必须常驻 —— 它启停的是引擎行,自己不能被同一个开关关掉,否则"窗口关闭 → 调度器也没了 → 永远醒不来"。

世界时钟:1 TU = 1 现实秒,每 30 TU 想一轮。


  tingleEveryUnits: 30,

  checkpointMs: 30_000,   // 磁盘检查点,跟节拍无关

重叠保护:一拍可能跑 19~27 秒,所以"上一拍没跑完就跳过这一拍"——宁可少一次,不叠。

这个 30 秒是被实测逼出来的。默认原本是 1800 TU(30 分钟一拍),我一度认为那很合理,省额度又"像个成年人的节奏"。改成 30 秒之后,同一时段的世界事务从 1 条变成 24 条,她第一次写出了日记似的东西。也就是说她不是"没什么想做的",是没被想起来过。反过来也成立:节拍快了要真金白银的额度,额度打满时她会停在半路 —— 默认值不能按想象的合理来定。

还有一点值得单独说:她可以发 wait/rest 主动进入等待,下限 10 分钟。这个下限是为了省额度,而代价是"群里来消息时她会等待被打断,但不叫模型"(机械结算)。设计意图是让她"等得起",而不是把每一次环境变化都变成一次调用。

六、人格是怎么进到模型里的

persona/base.md(说什么话、什么边界)、persona/profile.md(常驻前提)、persona/world.md(世界姿态)三个文件,由脚本生成三个 preset 的 agent.cordis.yml。生成产物不手改。

生成的时候只写结构与占位,人格文本在装机时嵌入到那一行的 config.prefix:


export const PERSONA_TEXT_KEY = 'prefix'

这个常量救过一次很难查的故障。发布版人格插件的 Config 是 {prefix, suffix, complete, includeRuntimeContext},而生成器当时写的是 text。三个 preset 全部挂载失败,报的是:


$.prefix missing required value

症状是她人格掉光、不回话。而真正阴的地方在于:本机形态跑的是源码版本,一切正常;只有装发布版 npm 包的容器形态会炸。同族的还有一次 —— QQ 号是纯数字,裸插进 YAML 被读成 number,profile 挂不上;这条现在靠"渲染后必须是 string"的断言守着。

判"人格有没有被人写过"也不能只看文件非空。出厂空模板里有大量标题和说明文字,hasContent 会判"已装好",于是新用户一路在空模板上创了世。真正的判据是有没有 TODO 行。

七、回复太长怎么办:分段发送

LLM 会写出一段很长的话,而 QQ 一条消息装不下,也不该装。第一反应是"每 200 字切一刀",但那不是人说话的方式。

现在的设计是:边界由她自己标。一行一句,整行用直引号包住:


"笨蛋爸爸,大半夜的还在跟代码打架啊。"

"这样吧,先停一下,别急着改。"

一个引号行 = 一条消息,外层引号只是边界标记,默认发出去时去掉。配置项与默认值:

键默认作用
maxReplySegments4最多拆几条;超出的并进最后一条,不是丢掉
maxReplyCharsPerSegment200单条上限(QQ 实收约 500,留余量)
replySegmentGapMs600两条之间隔多久,防刷屏
stripReplyQuotestrue外层引号去留
replyMixedLineskeep-all引号行与裸行混排时,默认不丢内容
maxReplyChars2000语义是总长

三条纪律,是按失败模式的严重程度排的:

  1. 回退是一等公民。引号风格是新加的,她今天还不会这么写。一个引号行都没有时必须整条发出 —— 否则"忘加引号"会变成完全不回话,而静默失败比切得难看严重一个量级。

  2. 不丢内容。混排时默认裸行也发。"只发引号行"更符合"独白不外发",但它的失败模式是吞掉她真正想说的话,而吞内容不可逆、多吐一句可恢复。

  3. 不切坏消息段。[CQ:image,file=…] 被从中间切断就废了,所以按长度切时把 CQ 码当原子。这条一开始我自己就违反了:总长截断用的是 text.slice(0, limit),会发出半截 CQ 码 —— 一串乱码字。

还有两条投递侧的规则:dryRun 时一条都不真发但日志写清"本应发第 i/N 条";某条失败不整体重发(前面的已经出去了,重发就是重复刷屏),继续发剩下的,最后给一条汇总。群里 @ 只挂在第一条上。

八、记忆:慢路径,以及它其实没有向量

一轮对话结束后写回记忆。这里第一件事是延迟预算:ReMe 的沉淀要在服务端跑 LLM,实测十几到二十几秒。如果把它接回对话链路,等于"记东西"拖住"回消息"。所以:


void memory.record({ messages: [...] }, sessionId)   // 不 await,永不阻塞

写回落盘成缓冲、按 batchRounds(默认 10 轮)攒批;沉淀失败只 warn,缓冲原样留着下轮一起再发 —— 宁可重复,不可丢。

但"不 await"很快变成另一个问题:测试怎么知道写回落地了?没有判据的时候,一条用例报"发出 2 条而不是 3 条",另一条报"少一个 session_id",手工跑六次全绿、提交钩子上随机红两次。最后是把"跑完了没有"做成实现自己回答的问题:桥提供 drainQueues()(配合从事件进门就开始记的 inFlight,只数队列不够 —— 中间还有两道 await 门,排空会跑在入队之前),记忆侧提供 pendingRoundsTotal()(含"已收下但还没落盘"那段)。修完连跑六轮全绿。

第二件事更基本:这套记忆没有向量检索。没配 embedding 组件时,召回实际是 BM25 字面匹配。我拿十条真实说法测了命中率:8/10。

这个数字直接决定了什么可以离开 prompt。"她记得具体发生过什么"可以交给记忆后端;"判断日程要以调休补课为前提"这类推理的前提不能 —— 字面匹配接不住换了说法的提问。于是人格文件被切成两半:设定与前提留在仓库,事实搬进记忆。

顺带一个环境坑,真实报错长这样:


UnicodeEncodeError: 'gbk' codec can't encode character '\U0031f4e6'

ReMe 第一次起不来就是这个。不是代码坏了,是 Windows 上 stdout 被重定向时富文本渲染器拿 GBK 编一个 emoji。解法是 PYTHONIOENCODING=utf-8。同族的还有用 curl 传中文 JSON 被 shell 按 GBK 编码,服务端只回一句 {"detail":"There was an error parsing the body"} —— 看起来像请求体格式错误,其实是编码。

九、五个坑,带原文

1)session "…" already exists,一天 49 次。 第一反应是"磁盘上有日志为什么被判成没有",去翻上游才知道持久化的 list() 是 readdir 直扫,没有缓存层;那条报错来自内存里的 store,而且检查发生在 resume 分支之前,走恢复一样撞。真凶是我们自己释放句柄时 void dispose() 不等:框架以为作用域拆完了,下一次装载撞上还没摘掉的同 id 条目。全部改成 await 就够了吗?不够 —— 引擎用布尔标记"这次打开是否作废",而布尔会被下一次装载复位成 false,于是还没跑完的旧 open() 醒来以为自己还活着,跟新那轮抢同一个会话。换成装载代数才收干净。

2)我以为模型不肯调工具,其实是被限流了。 世界引擎一直不产出动作,日志说"模型没有调用工具"。我据此把 maxTokens 提了一档(理由是"截断会丢 tool-call"),没用。解压真实会话日志才发现:429 是以 finish=error 送进来的,不抛异常,而代码只检查有没有 tool-call 块。实测那一类截断是 0 例,我的改动建立在一个已被证伪的假设上,只能回滚,并在代码里留一句"这个值是错误的判断涨上去的"。更糟的是这条假结论会被喂进重试提示,一次心跳白烧两次调用。先加可观测性,再改参数。

3)内核的工程说明书进了她的内心独白。 她的独白里出现过:


SQLite uses monotonic SCHEMA_VERSION

以及"Remove this section at the first tagged release"这类明显是给改 DSH 代码的人看的话。原因是世界会话没带 cwd,落到了进程目录 —— 那是内核的检出目录,里面有内核自己的 AGENTS.md,而 agent 会把工作区上层的说明文件注入 prompt。不是报错,是她读错了自己该读的东西。修法是三处 cwd 必须一致,并且有一条测试断言它们全等。

4)cannot get property "agents" without inject。 一个共用助手内部用了 ctx.agents。桥声明了 inject 所以合法,世界引擎故意不 inject(它要能独立裁决),于是同一份代码在那边每拍都抛。真实后果是:她永远不会主动做任何事,而单元测试全绿(假 ctx 不提供这个属性)。修法是助手不再自己碰 ctx,依赖由调用方显式传进来。

5)测试自己骗人。 上面第 1 条为什么攒了 49 次才被复现?因为假装配体里的假 agent 压根没有 status 字段,status !== 'idle' 恒真,于是"释放空闲句柄"在测试里从不释放任何东西;那个假注册表也没有撞车检查、dispose 还是同步的。三个都补上,复现测试立刻红。假环境少一个字段,测试就会假绿 —— 这条比大多数产品 bug 更难查,因为它长得像"我们已经测过了"。

十、这套做法的局限

诚实列一下,避免读者照着踩。

单账号是设计选择不是能力:一个 bot 实例、按场景切换三套 preset,好处是一致的人格与记忆,代价是"她"和"运营者"共用一个号,权限要靠 preset 的裁剪保证 —— 而裁剪只约束 harness 自己发起的东西,MCP 和 shell 内部的出网绕得过它。shell 在本机形态没有沙箱(上游没有 win32 runner),它就是本机任意代码执行能力。安全审计没跑完,所以这篇不说"安全",只说哪些路径上有门。群聊上下文缓冲在内存里,重启即空 —— 长期靠记忆后端,不靠缓冲。出站目前只会发文本,图片和表情都还发不出去。记忆是 BM25,不是向量。

还有一个我觉得值得单独讲的:主动性是有预算的。每拍一次模型调用,30 秒一拍在四小时窗口里就是几百次。系统的"活得像人"和"跑得起"之间没有免费午餐,只有可调的默认值 —— 而这个默认值必须由实测的产出与成本决定,不能由审美决定。

十一、数字与历程

十天,61 个提交。

项数
源文件 / 源码行数74 / 约 1.69 万
装配声明里的插件行47
spec 文件 / 用例71 / 1303
控制台页面10
部署形态3(本机 / 公开包 / 单镜像)

过程大致是:前五天把地基、消息通道、溯源、能力隔离跑通;中段接记忆与控制台;后段做世界引擎、单镜像,以及两次被真实环境打脸后的返工(49 次 already exists、只在容器形态炸的 prefix)。

中间有一次方向修正很关键:从"多个机器人账号各司其职"改成"一个账号按场景切能力"。前者好实现,但她在群里、在私聊、在自己的世界里会变成三个人 —— 一致性没了。

还有两条纪律是撞出来的。一条是真实标识不进仓:她的 QQ 号、主人的 QQ 号、真实群号一度散在 52 个文件 279 处,而其中一份文档要推到公开仓。一次性替换不难,难的是别回流 —— 守卫文件本身也在扫描范围内,黑名单得拆成两段存,否则那个测试就是把真值请回仓库的凶手(no-useless-concat 还会一直劝你合并成一条字面量)。另一条是测试不依赖这份包有没有真内容:向导那两条线原先拿仓库自己的 persona/ 当"装备齐全"的现场,而开源包的 persona 按设计是空模板,于是 16 条用例在别人拿到的包里必红 —— 而"开源包的 pnpm test 才代表别人能不能跑绿"这句话,恰好写在项目自己的文档里,没人执行过,烂了半个多月。

十二、如果只留四句

  1. 静默失败是这一类系统的主要成本。 凡是"没发生"看不见的事,先做成可观察:带 id 的 warn、实现自己提供的排空判据、读 schema 的守卫。

  2. 兜底要谨慎。 "resume 失败就 create"、"参数不对就调大"这两个直觉都制造了更难查的事故。宁可如实失败,不要静默降级。

  3. 两套部署形态就是两份现实。 只验一条的结论不可信;键名、类型、路径这类"只在一边炸"的,收成单一来源加机械守卫。

  4. 别相信没测过的假设。 我对模型行为、限流、节拍、记忆召回的判断里错了两次,错的那两次都是因为当时没去解压真实日志。

相关

  • DSH / cordis:@deepseek-ai/dsh(npm),插件模型见其 agent / session / presets 三包

  • OneBot v11:QQ 机器人社区的消息协议;实现有 NapCat 等,反向 WebSocket 由实现侧拨入

  • ReMe:github.com/agentscope-ai/ReMe(本项目容器形态里 vendored 的那份,Apache-2.0,改动记录随源码)

继续阅读

全部归档

评论