乐于分享
好东西不私藏

源码追踪|揭开 DSH 提示词的神秘面纱

源码追踪|揭开 DSH 提示词的神秘面纱

博主最近在挖 DSH 源码的时候,发现 prompt 字符串到处都是,事件类型一大堆,可就是串不起来 —— 模型到底什么时候看到了什么?工具调用又是怎么绕一圈回到程序的?单看某一处代码,好像都认识,但拼在一起就成了一团迷雾。

本文从用户发一句话开始,详细讲解 DSH 背后提示词的完整流向。

01
分清五种数据

在同一个 “用户提问 → 模型回答 → 工具执行” 的过程中,DSH 会使用五种不同的数据,理解它们,是读懂源码的关键所在

最容易混淆的是后两项(真正作为后续对话历史发给模型的是后者):

  • assistant/chunk: 用于保存流式输出的原始增量;
  • assistant/message: 则是这些增量组装完成后的完整助手消息。
工具调用也是如此,日志中保留原始参数字符串的tool/call,而下一轮请求中模型看到的是包含 tool-result 块的结果消息。
02
完整的数据流程

假设用户说:“请读取 README.md,告诉我项目是做什么的”,一次 turn (用户任务)会经过下面的路径:

注意:turn是用户一次请求的完整处理过程;step是其中“一次模型请求,加上该次模型请求要求的所有工具调用”,模型只返回文本时,一个 turn 通常只有一个 step,模型先调用 read、拿到文件内容、再回答时,则是同一个 turn 中的两个 step。

03
流程详解
1
用户输入之后,程序先做什么?

前端或 SDK 会把用户文本包装成 UserMessage,并通过 agent.followup() 放进 Agent 的 inbox。

它此时还不是永久对话历史,Agent Loop 会先打开 turn/start,领取本轮输入,并运行 agent/pre-step waterfall,这个扩展点可以拒绝输入、改写输入,或补充一次性的上下文。

在pre-step 之前, Agent Loop 会调用 ctx.systemPrompt.assemble(),所有已挂载插件在这里贡献本轮的 section、context、变量以及可见工具 schema
运行时上下文会被做成一条额外的 user-role 快照消息,只有快照与上一次不同才会加入,避免每一步重复一整段时间、沙箱或审批信息。

pre-step 接受后,Agent Loop 先写入 step/start,再把 用户输入这条动态快照 依次追加为 user/message 事件。

这就是 “模型可见的信息必须被记录” 的实际落点:不是从内存里悄悄塞一段 prompt,而是先记入 Session,再由 Session 统一投影给模型。

2
这一刻实际传给模型的内容是什么?

Agent Loop 用 session.deriveMessages() 从 Session 的模型可见事件中重建历史,并和刚组装的系统提示词、工具 schema 一起形成 GenerateOptions。

它的抽象结构如下;system、messages、tools 是三条独立通道,不要把它们都理解成一大段 system prompt。

{  provider: 'deepseek',  model: '...',  system: '身份、persona、工具使用规则等 section 拼出的文本',  messages: [    { role: 'user', content: [{ type: 'text', text: '读取 README.md,告诉我项目是做什么的。' }] },    { role: 'user', content: [{ type: 'text', text: 'Current runtime context. ...' }] },  ],  tools: [    {      name: 'read',      description: 'Read a UTF-8 text file and return line-numbered content.',      parameters: { type: 'object', properties: { file_path: { type: 'string' } }, required: ['file_path'] },    },  ],  sessionId: '...',  signal: AbortSignal,}

上面的 TypeScript 是 Harness 内部的提供方无关表示。

dsh-llm-deepseek 会:

  • 把 system 放到 DeepSeek messages 数组的第一条 { role: 'system' }
  • 把普通历史转换为 { role: 'user' } 或 { role: 'assistant' }
  • 把工具 schema 转成 DeepSeek 的 tools: [{ type: 'function', function: { name, description, parameters } }]
  • 请求还会带上 stream: true 和 stream_options: { include_usage: true }

稳定规则通常进入 system,当前状态成为一条用户消息,工具能力主要进入 tools,而工具、计划、子 Agent 等模块也可能同时贡献这几种内容。

3
模型返回的工具调用,程序怎样解析?

DeepSeek 不会返回 JavaScript 函数调用,它以 SSE 持续返回 JSON 增量

当模型选择工具时,增量位于 choices[].delta.tool_calls,其中包含调用 id、函数名和分段到达的 function.arguments 字符串。例如,完整拼合后可能是:

{  ”id”: ”call_read_123”,  ”type”: ”function”,  ”function”: {    ”name”: ”read”,    ”arguments”: ”{\”file_path\”:\”README.md\”}”  }}

这里有两层转换,不能跳过。

  • packages/llm/llm-deepseek/src/translate.ts
    读取 SSE,分别把文字、推理内容和每一个工具调用翻译成统一的 StreamChunk(例如: text-deltareasoning-deltatool-call-deltablock-end 和最终的 finish)。
  • Agent Loop
     一边把每个 StreamChunk 记录为 assistant/chunk,一边交给 BlockAssembler,流结束后,BlockAssembler 按 block index 拼回完整的 textreasoning 和 tool-call 块,生成一条 assistant/message

于是上例在 Harness 内部变成:

{  type'tool-call',  id'call_read_123',  name: 'read',  arguments: '{”file_path”:”README.md”}',}

arguments 在这一刻仍是模型返回的原始字符串,这样 Session 能精确回放模型究竟请求了什么。

真正准备执行时,调度器才尝试 JSON.parse(),空字符串按 {} 处理,非法 JSON 会以原字符串继续进入工具的参数校验,最终成为可返回给模型的错误结果,而不会把解析异常直接当作宿主程序崩溃。

4
工具执行还需要经过一条管线

Agent Loop 从 assistant/message 中筛出全部 tool-call 块,按模型给出的调用顺序写入 tool/call 事件

可并行的工具可以同时运行,但结果和额外上下文仍按模型调用顺序提交,所以后续历史不会因为完成时间不同而乱序。

每个调用交给 ctx.tools.execute() 后,会经过以下步骤:

  1. ToolRuntime
     复制并冻结参数,解析当前 Agent scope 下可见的工具定义。
  2. tools/pre-execute
    执行策略,它可以允许、拒绝或要求用户审批,之后还会执行不能被前置插件放宽的 guard。
  3. tools/execute
     waterfall 调用真正的工具实现。例如 read 调用文件系统 provider,bash 通过 shell/subprocess provider 启动命令,web_search 调用 Web provider,工具本身只依赖它所属的能力接口,因此本地实现和远程沙箱实现可以互换。
  4. 对由 defineTool() 定义的工具,工具实现前会按它发送给模型的同一份 JSON Schema 校验参数;无效参数会变成 INVALID_ARGS 错误结果。成功返回值也会按输出 schema 校验并渲染为模型可读的 ContentBlock[]
  5. tools/post-execute
     可以接受、替换、补充或屏蔽结果,最后工具定义的 finalizeContent 和 tools/result 观察者处理最终结果。

最后,Agent Loop 写入两类事件:tool/call 保存工具名和原始参数,tool/result 保存带同一 callId 的模型可读结果,后者会投影为内部的 user-role ToolResultMessage

{  role: 'user',  content: [{    type'tool-result',    toolCallId: 'call_read_123',    content: [{ type'text', text: '1:# DeepSeek Harness\\n...' }],    isError: false,  }],}

DeepSeek adapter 再把它转换为 wire 上的 { role: 'tool', tool_call_id: 'call_read_123', content: '...' },这条消息必须紧跟产生该 id 的助手工具调用消息,模型才能把结果与请求对应起来。

4
为什么工具结果会让模型再回答一次?

工具执行完不代表 turn 结束。

Agent Loop 会开始同一 turn 的下一 step,重新组装本轮的 prompt 和当前可见工具,再从 Session 导出历史。

这份历史已经包含:原用户问题、模型刚才的 tool-call、以及 tool-result,模型因此能读到 README.md 的内容,并输出“这个项目是……”

如果这次响应没有 tool-call 块,Agent Loop 记录最终 assistant/message,依次写入 step/end 和 turn/end,Web UI 或 SDK 从 Session 事件中显示答案。

换句话说,工具结果不是由程序替模型拼进最终答案,而是作为下一次模型请求的输入。模型仍然负责决定如何解释结果、是否继续调用工具、以及怎样对用户作答。

04
文末总结

回看整个过程,DSH 的 prompt 流可以浓缩成一条主线:一切可见内容,必须先进 Session,再被投影给模型。

  • 五种数据是理解源码的地图
    尤其是 assistant/chunk ,assistant/message,tool/call 和 tool/result 的区别——前者是原始记录,后者才是模型下一轮真正看到的内容。
  • 每个 step 都重新组装一次完整上下文
    系统提示词、运行时快照、历史消息、工具 schema 分别在 system、messages、tools 三条通道中传给模型,而不是塞进一大段文本。
  • 工具调用是"执行—写回—再请求"的循环
    模型返回 tool-call 后,程序执行并把结果写成带同一 callId 的 user-role 消息。下一轮请求时,模型重新阅读完整历史,自己决定如何解释结果。
  • provider 边界只做翻译,不改逻辑
    内部的 tool-result 消息和 DeepSeek 的 role: "tool" 是同一个东西的两种表示。

如果在源码里遇到 prompt 相关代码时,不妨从这三个角度去思考,这样 DSH 的 prompt 流向就不再是迷雾

这条消息写入 Session 了吗?它在下一轮 deriveMessages 时会被投影出来吗?provider adapter 会把它翻译成什么格式?

    感谢大家的阅读,希望能帮助到大家,本文完!