博主最近在挖 DSH 源码的时候,发现 prompt 字符串到处都是,事件类型一大堆,可就是串不起来 —— 模型到底什么时候看到了什么?工具调用又是怎么绕一圈回到程序的?单看某一处代码,好像都认识,但拼在一起就成了一团迷雾。
本文从用户发一句话开始,详细讲解 DSH 背后提示词的完整流向。
在同一个 “用户提问 → 模型回答 → 工具执行” 的过程中,DSH 会使用五种不同的数据,理解它们,是读懂源码的关键所在。

最容易混淆的是后两项(真正作为后续对话历史发给模型的是后者):
assistant/chunk: 用于保存流式输出的原始增量;assistant/message: 则是这些增量组装完成后的完整助手消息。
工具调用也是如此,日志中保留原始参数字符串的tool/call,而下一轮请求中模型看到的是包含 tool-result 块的结果消息。假设用户说:“请读取 README.md,告诉我项目是做什么的”,一次 turn (用户任务)会经过下面的路径:

注意:turn是用户一次请求的完整处理过程;step是其中“一次模型请求,加上该次模型请求要求的所有工具调用”,模型只返回文本时,一个 turn 通常只有一个 step,模型先调用 read、拿到文件内容、再回答时,则是同一个 turn 中的两个 step。
前端或 SDK 会把用户文本包装成 UserMessage,并通过 agent.followup() 放进 Agent 的 inbox。

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


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

这就是 “模型可见的信息必须被记录” 的实际落点:不是从内存里悄悄塞一段 prompt,而是先记入 Session,再由 Session 统一投影给模型。
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放到 DeepSeekmessages数组的第一条{ 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 等模块也可能同时贡献这几种内容。
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-delta、reasoning-delta、tool-call-delta、block-end和最终的finish)。Agent Loop一边把每个 StreamChunk记录为assistant/chunk,一边交给BlockAssembler,流结束后,BlockAssembler按 block index 拼回完整的text、reasoning和tool-call块,生成一条assistant/message。
于是上例在 Harness 内部变成:
{type: 'tool-call',id: 'call_read_123',name: 'read',arguments: '{”file_path”:”README.md”}',}
arguments 在这一刻仍是模型返回的原始字符串,这样 Session 能精确回放模型究竟请求了什么。
真正准备执行时,调度器才尝试 JSON.parse(),空字符串按 {} 处理,非法 JSON 会以原字符串继续进入工具的参数校验,最终成为可返回给模型的错误结果,而不会把解析异常直接当作宿主程序崩溃。
Agent Loop 从 assistant/message 中筛出全部 tool-call 块,按模型给出的调用顺序写入 tool/call 事件。
可并行的工具可以同时运行,但结果和额外上下文仍按模型调用顺序提交,所以后续历史不会因为完成时间不同而乱序。

每个调用交给 ctx.tools.execute() 后,会经过以下步骤:
ToolRuntime复制并冻结参数,解析当前 Agent scope 下可见的工具定义。 tools/pre-execute执行策略,它可以允许、拒绝或要求用户审批,之后还会执行不能被前置插件放宽的 guard。 tools/executewaterfall 调用真正的工具实现。例如 read调用文件系统 provider,bash通过 shell/subprocess provider 启动命令,web_search调用 Web provider,工具本身只依赖它所属的能力接口,因此本地实现和远程沙箱实现可以互换。对由 defineTool()定义的工具,工具实现前会按它发送给模型的同一份 JSON Schema 校验参数;无效参数会变成INVALID_ARGS错误结果。成功返回值也会按输出 schema 校验并渲染为模型可读的ContentBlock[]。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 的助手工具调用消息,模型才能把结果与请求对应起来。
工具执行完不代表 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 事件中显示答案。
换句话说,工具结果不是由程序替模型拼进最终答案,而是作为下一次模型请求的输入。模型仍然负责决定如何解释结果、是否继续调用工具、以及怎样对用户作答。
回看整个过程,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 会把它翻译成什么格式?
感谢大家的阅读,希望能帮助到大家,本文完!
夜雨聆风