No description
  • JavaScript 99.2%
  • PowerShell 0.8%
Find a file
2026-08-20 19:25:12 +08:00
lib fix: glm-xml 拒绝非法工具名,防泄漏示例被误判为 unknown tool 2026-08-20 19:25:12 +08:00
tests feat: empty-reply triggers retry + masker/parser DSML alignment 2026-08-20 13:56:56 +08:00
.gitignore update gitignore 2026-08-18 23:45:50 +08:00
install.ps1 fix: install.ps1 兼容 Windows PowerShell 5.1 的 Join-Path(只接受两段) 2026-08-20 16:45:19 +08:00
package.json refactor: parse builtin tool-call protocol 2026-08-19 18:29:06 +08:00
README.md feat: empty-reply triggers retry + masker/parser DSML alignment 2026-08-20 13:56:56 +08:00

dsh-llm-hiagent

原生 DeepSeek Harness LLM 提供方插件,把同济 HiAgent(会话式智能体 API)映射为 DSH 的 llm 服务适配器。不需要单独的 Python 代理进程——转换逻辑全部在进程内完成。

适配自 liquidhelium/tongji-hiagent-proxy (原为 OpenClaw 设计的 FastAPI 代理),核心机制相同,但以原生 LlmAdapter 实现:

  • 会话方式(默认:每次新建会话 + 完整上下文):默认 noReuse: true 时,不复用 HiAgent 会话——每次请求都新建一个 AppConversationID,把完整对话历史(含 [user]/[assistant]/[tool:...] 段落)作为只读上下文一次性注入首条消息,彻底摆脱 服务端 ~3 轮滑动窗口对记忆的锁死,也不依赖 hiagent-conversations.json 映射。需要旧式 复用时设 noReuse: false(见「记忆与滑动窗口」)。
  • 会话映射(noReuse: false 旧模式):按消息哈希(到首个 assistant 轮为止)复用 HiAgent 的 AppConversationID,映射持久化到 $DSH_HOME/hiagent-conversations.json。
  • 工具调用:模型使用 DeepSeek 原生标准 <│DSML│tool_calls> 格式。每轮刻意注入 DSML 工具调用格式指令(## Tool calling format + 完整 DSML 示例块),并在重发的历史里把 assistant 的工具调用序列化为原生 DSML(toolCallsToDsml)而非 OpenAI wire JSON—— 这样历史本身就成为可供模仿的 DSML 教师样例,避免模型改去模仿 wire JSON 数组。模型输出 DSML 块后由 parseDsmlToolCalls 解析为 DSH 的 tool-call 块。每轮重注入工具 schema 与 DSML 指令是因为 HiAgent 会话有内置滑动窗口(实测只保留最近约 3 轮),首轮注入的内容 会滚出窗口。
  • 思考( think_message 事件):v2 规范下思考由独立 think_message 增量下发,插件按事件 类型路由,无条件进入 DSH 的 reasoning 块;message 增量才是可见 text。不依赖 start/end 段落状态机(服务端可能在思考中途错误插入 end/message_start,依赖状态机会泄漏思考到可见文本)。 请求侧不回传 reasoning(HiAgent 会话记忆自带)。
  • 真流式:HiAgent 网关在收到 Accept: text/event-stream 头时会把底层 Dify SSE 流增量冲刷 (实测首字节 ~1s、逐 token 事件);插件实时渲染可见文本与思考,并实时遮蔽 DSML 工具区 工具区域,结束时做权威解析。作者注:流式 masker 只做 DSML 屏蔽,不再做任何 think 标签剥离——思考只来自 think_message 事件,parseAnswer 也直接解析原始 DSML 调用。
  • DSML 解析与截断恢复:解析器只认 DeepSeek 原生 <│DSML│tool_calls>,并把 string="true|false" 正确还原为字符串/JSON 参数;不注入任何自定义 [TOOL_CALL_START] 格式。针对服务端在 think 后随机吞掉首帧开头字节的问题,解析器还支持残缺开标签的 relaxed 恢复。
  • 上下文研究结论(实测):内部模型为 ~128k 上下文级别(>~20 万 token 的提示会返回"大模型 接口调用出错,请联系管理员"作为答案,~14 万 token 正常);contextWindow 已按此声明为 128000,DSH 不再每两三轮就压缩。会话滑动窗口约 3 轮,输入 token 数不随会话无限增长。
  • 会话轮换续接:复用会话超过 maxReuseTurns(即插件设置里的「对话轮数保留」,默认 100、最大 100)轮后,插件会新建一个 HiAgent 会话并处理当前请求。这样重置了服务端 ~3 轮的滑动窗口,且输入规模始终有界(0 关闭轮换)。
  • 首条消息限流:新建 HiAgent 会话时只发送最近 maxInitialMessages(默认 120)条消息、 估算 token 不超过 maxInitialTokens(默认 100000)、字符不超过 maxInitialChars(默认 800000),避免把 DSH 超长历史一次性塞给 HiAgent 导致上下文超限。正常会话远低于这些上限; 各值设 0 即关闭对应限制。

安装

  1. 把本包放进 profile 的 node_modules(本机已就位):

    ~/.dsh/profiles/node_modules/dsh-llm-hiagent/
    
  2. 在 ~/.dsh/profiles/web/cordis.patch.yml 加入一行(本机已加):

    - insert:
        - id: llm-hiagent
          name: dsh-llm-hiagent
          config:
            apiKey: <hiagent-api-key>        # 或 apiKeyEnv: HIAGENT_API_KEY
            baseURL: https://agent.tongji.edu.cn/api/proxy/api/v1/
            injectSystemPrompt: true         # agent 未内置 prompt 时为 true
            modelId: hiagent
            displayName: HiAgent (Tongji)
            contextWindow: 128000
            maxTokens: 4096
    
  3. 重启 dsh(Ctrl+C 后重新运行启动命令),然后在模型选择里选 HiAgent (Tongji)。

配置界面

  • 设置 → 插件 → 可配置:有一张 "HiAgent (Tongji)" 卡片(client 半实现,lib/client.js),可编辑 模型列表(每个模型绑定一个 API Key,对应一个 HiAgent agent)+ 共享配置,写入 settings.yaml 的 llm-hiagent: 段,实时生效。每个模型行的 API Key 是写入型字段:填了新值才写、留空保持不变 (设置线路上密钥只写不读),所以后续修改其他字段不会清掉已配置的 Key。
  • 也可以直接编辑 settings.yaml 的 llm-hiagent: 段(组合层配置为基线,用户层覆盖)。
  • 提示:宿主代码与首次新增的 client 半需要重启才加载(client-modules 图在插件重新激活时 增量扫描);之后修改 lib/client.js 刷新页面即可生效。

多模型

HiAgent 每个发布出去的 agent 有独立 API Key,底层模型可能不同。配置 models 列表即可:

llm-hiagent:
  models:
    - id: hiagent-glm          # 模型选择器里显示为 hiagent/glm
      name: HiAgent GLM
      contextWindow: 128000
      maxTokens: 4096
      maxReuseTurns: 50        # 可选:该模型自己的对话轮数保留(覆盖顶层,不设则用顶层)
      noReuse: false            # 可选:该模型关闭"每次新建会话"(覆盖顶层;不设则继承顶层)
    - id: hiagent-ds
      name: HiAgent DeepSeek
      contextWindow: 128000
      maxTokens: 8192
  modelKeys:                    # 每个模型的独立 API Key(写入型,UI 里填)
    hiagent-glm: <agent1-key>   # 该 agent 的 API Key
    hiagent-ds: <agent2-key>
  • 每个模型的 Key 放在顶层 modelKeys 字典里(按模型 id),而不是 models[i].apiKey。原因: 配置线路上密钥只写不读(redacted),若把 Key 塞在 models 数组里,任何一次整表保存都会把已配置的 Key 静默清掉(旧 bug 的根因)。modelKeys 逐条写入,互不影响。旧式 models[i].apiKey 仍被读取 (向后兼容),但 modelKeys 优先。
  • 每个模型的 apiKey/modelKeys 可留空 → 继承顶层 apiKey(或 apiKeyEnv 解析)。
  • 会话映射按模型隔离(会话 key 带模型盐),不同 agent 的对话不会串。
  • 不配置 models 时退化为顶层字段定义的单个默认模型(向后兼容)。

记忆与滑动窗口

HiAgent 会话有内置滑动窗口(实测只保留最近约 3 轮),而插件为了复用会话会把单次请求只发增量—— 这样模型能看到的记忆被锁死在约 3 轮内(会话越长越明显)。默认的 noReuse: true 模式直接绕开 这个问题:每次请求新建 HiAgent 会话,并把 DSH 侧完整的历史消息序列化进首条消息作为只读上下文, 模型每次都能看到全部记忆,不受服务端窗口限制;代价是每次都要重发完整上下文(token 成本上升)。

noReuse: false 时回到旧式复用:把单次请求只发增量,通过 maxReuseTurns(默认 100,即「对话轮数 保留」)控制复用轮数,达到上限后新建 HiAgent 会话以重置滑动窗口。每个模型可通过 models[i].maxReuseTurns、models[i].noReuse 单独覆盖。

会话复用 key 把 DSH 会话标识(options.sessionId) 并入盐:会话 key = hash(模型盐 + sessionId + 消息前缀)。DSH 的 fork 会给子会话铸造新 id,因此 fork 出的分支即使与前缀消息相同,也会映射到 独立的 HiAgent 会话,不会与父分支串上下文(否则两个分支共享同一个 AppConversationID,模型 会看到另一个分支的记忆——实测工具调用结果错位、上下文重放的根源)。同一会话内 sessionId 稳定, 复用行为不变;无 sessionId 的一次性调用回退到纯消息哈希。

配置项

字段 默认 说明
apiKey — 默认模型/回退的 HiAgent API 密钥(直接内联)
apiKeyEnv — 密钥的环境变量/凭据引用(与 apiKey 二选一,优先 apiKey)
models — 模型列表,每项 {id, name, apiKeyEnv, contextWindow, maxTokens, maxReuseTurns}(Key 见 modelKeys;maxReuseTurns 可选覆盖顶层)
modelKeys — 按模型 id 的独立 API Key 字典(写入型;优先于 models[i].apiKey 与顶层 apiKey)
maxReuseTurns 100 复用会话达到该轮数后新建 HiAgent 会话(即「对话轮数保留」,重置滑动窗口;0=关闭轮换;最大 100)。仅 noReuse: false 时生效
noReuse true 每次新建会话:不复用 HiAgent 会话,每次请求新建并把完整历史作为只读上下文发入首条消息(消除 ~3 轮窗口锁死;代价是每次重发完整上下文)。false 恢复旧式复用
baseURL https://agent.tongji.edu.cn/api/proxy/api/v1/ 必须以 / 结尾(端点名是拼接的)
userId default_user HiAgent 用户 ID
injectSystemPrompt true agent 未内置系统提示时,向首条消息注入 DSH 系统提示 + 工具 schema
modelId hiagent 未配置 models 时的默认模型 id
displayName HiAgent (Tongji) 选择器里的显示名
contextWindow 128000 内部模型实测上下文(~128k 级;>20 万 token 会报错)
maxTokens 4096 元数据:输出上限(HiAgent 实际不限制)
maxInitialMessages 120 新建会话时最多发送的历史消息数(防御超限;0=不限)
maxInitialTokens 100000 新建会话时首条提示的 token 估算上限(CJK≈1 字/token;0=不限)
maxInitialChars 800000 新建会话时首条提示的字符上限兜底(0=不限)
conversationStorePath $DSH_HOME/hiagent-conversations.json 会话映射持久化文件

已知坑(适配时的关键点)

  1. BASE_URL 必须以 / 结尾,否则 create_conversation 拼成 ...v1create_conversation 404。
  2. 系统提示会很大。DSH 的系统提示(含全部工具 schema)可能上万 token,而 OpenClaw 版声明 的上下文只有 16000。插件把 DeepSeek 原生 DSML 工具协议和工具列表放在 system 段最前面, 即使 HiAgent 截断长首条消息,模型也看得到工具协议。若实际遇到截断,调大 contextWindow。
  3. 工具结果必须以 [tool:<callId>] 段回传。DSH 的工具结果消息是 role:"user" + tool-result 块,不能按普通 user 文本处理,否则模型收不到结果只能瞎编(实测踩过)。
  4. DSML 解析与思考互不干扰:思考由独立的 think_message 事件进入 reasoning 块;可见 message 增量直接解析 DSML 调用,两者来自不同事件通道,无需担心思考内容被当成工具参数。
  5. 不再做 think 标签剥离:旧版靠 splitThinkTags 结构感知剥离 thinking / <thinking> 标签。 v2 规范下思考以独立 think_message 增量下发,流式 masker 与 parseAnswer 都不再剥离任何 think 标签——思考只来自事件路由,避免误剥模型想展示的标签字面量。
  6. 流式思考按事件类型路由,不依赖段落状态机:v2 规范下思考以独立 think_message 增量下发, 插件按事件类型无条件送入 reasoning;message 增量才是可见 text。绝不依赖 start/end 段落 状态机(服务端可能在思考中途错误插入 end/message_start,状态机会泄漏思考到正文)。无旧通道回退。
  7. parseDsmlToolCalls 与 masker 大小写不敏感 + 代码感知 + 截断恢复:DSML 标签匹配 大小写不敏感(masker 与权威解析必须一致,否则实时显示与最终块错位),并跳过代码区域内的 字面示例。解析器只认 tool_calls 块内的 invoke,正文里的格式教学示例不会被误判。 string="true" 按字符串原样取;string="false" 按 JSON 解析,裸控制字符由 normalizeArguments 宽容处理。
  8. masker 保留 markdown 代码定界符(反引号/围栏):TagMasker 只把 ` 与 ``` 当作代码区域的定界符来跟踪(区域内标签保持惰性),定界符本身是字面文本,必须照常输出。 旧实现吞掉开头的反引号/围栏,已修复:masker 输出定界符;emitResultTail/synthesizeReplyBlocks 优先取权威 content(仅当 content 为空时回退流式 visibleText,如工具调用旁白)。
  9. 服务端会随机吞掉首帧开头字节:tool_calls 开标签的第一帧可能被网关丢掉开头 K 字节 (实测 K=0~13),导致开标签残缺或完全消失、DSML 严格块解析失败。 对策(已实施):parseDsmlToolCalls 先做严格块解析;严格找不到时进入 relaxed 恢复—— 直接扫描 invoke,只要后面存在 tool_calls 闭合,或 invoke 前存在残缺的开标签后缀 (如 tool_calls>、calls>),就把它解析为真实 tool call。这样即使丢了开标签前缀也能恢复。
  10. 模型不一定会按协议输出:同一个 HiAgent agent 可能随机拒答或改用纯文本。这是模型侧行为, 代理层只能保证提示词完整。若频繁发生,考虑换 agent / 调 prompt。
  11. 会话复用的 key 必须两端一致:保存时用「请求消息 + 合成回复块」,下次请求用「历史里的 实际块」——序列化必须确定(serializeContent 固定了块字段),否则每轮都新建会话。
  12. reasoning 能力不声明:模型未声明 reasoningEfforts,选择器不会提供 effort 选项,请求 也不带 thinking 参数;思考由 think_message 事件路由成 reasoning 块,不做请求侧 think 标签回传。
  13. 「模型无法理解你的问题或回复内容为空」作为失败触发 DSH 重试:HiAgent 有时以「正常完成」 方式返回这句兜底失败文案(而非真实回答)。适配器在解析最终回答时,若整段文本「精确匹配」 (两端 trim 后)内置的 HIAGENT_EMPTY_REPLY_MARKERS 列表,就把它标记为 DSH 原生错误码 EMPTY_RESPONSE 抛出。由于 EMPTY_RESPONSE 在 dsh-llm-retry 的默认可重试码集合内, DSH 会按重试策略(默认最多 2 次、指数退避)自动重新发起该模型请求。列表按精确整串匹配, 不做宽容/子串匹配以免误伤用户恰好问出相同句子的正常回答;遇到新的失败变体,向该数组追加即可。

DSML 截断恢复的设计

服务端在 think 后可能随机吞掉 <│DSML│tool_calls> 开头的 K 字节(实测 K=0~13)。为了在 不引入自定义格式的前提下恢复工具调用,解析器采用两层策略:

  • 严格模式:只匹配完整的 <│DSML│tool_calls>...</│DSML│tool_calls> 块,并跳过 markdown 代码区域,避免正文里的教学示例被误判。
  • relaxed 恢复:严格模式没有命中时,直接扫描 <│DSML│invoke>。只要 invoke 后面存在 </│DSML│tool_calls> 闭合标签,或 invoke 前面存在残缺的开标签后缀(tool_calls>、 calls> 等),就判定为真实 DSML 工具调用并解析。
  • 不做“纯 JSON 兜底”,避免把正文中的 JSON 误判成工具调用。

实测验证:think 后丢前缀时,只要 <│DSML│invoke> 和闭合 </│DSML│tool_calls> 仍幸存, 工具调用即可恢复解析。

测试

node test_adapter.mjs     # 直接驱动 HiAgentAdapter(真实 API)
node test_fullstack.mjs   # 独立 Cordis 应用 + 真实 LlmRuntime + 插件(真实 API)

源码目录 C:\code\hiagent\dsh-llm-hiagent,已同步到 C:\Users\helium\.dsh\profiles\node_modules\dsh-llm-hiagent。