LLM 消息、流式输出与 Tool Calling

学习目标

读完后,你应当能够:

  • 区分 system、user、assistant 和 tool 消息;
  • 理解模型返回最终文本和返回 Tool Call 的区别;
  • 解释 Tool Call 的完整闭环;
  • 理解流式 delta 与最终结果的关系;
  • 看懂 nanobot 的 LLMResponse、ToolCallRequest 和工具结果消息。

前置知识

  • 会读 Python 字典和列表;
  • 知道 JSON 是结构化数据格式;
  • 对 LLM 有基本使用经验即可。

推荐补充:

1. LLM 请求是一段有角色的消息序列

最小示意:

[
  {"role": "system", "content": "You are a helpful assistant."},
  {"role": "user", "content": "列出工作目录中的文件"}
]

角色不是展示样式,而是 Provider 和模型协议的一部分:

role典型职责
system身份、规则、工具契约、运行上下文
user用户请求或系统注入的后续输入
assistant模型文本、推理相关字段或 Tool Call
tool某个 Tool Call 的执行结果

nanobot 的 ContextBuilder 会组合:

  • system prompt;
  • workspace 中的 bootstrap 文件;
  • Skills;
  • Memory;
  • Session history;
  • 当前用户消息;
  • 运行时 metadata。

2. Tool Call 是“执行建议”,不是已经执行

模型可能返回类似:

{
  "name": "list_dir",
  "arguments": {
    "path": "."
  }
}

这只表示模型希望调用工具。运行时还必须:

  1. 找到对应工具;
  2. 解析参数;
  3. 根据 schema 校验参数;
  4. 检查工具是否允许执行;
  5. 执行工具;
  6. 把结果与正确的 Tool Call ID 关联;
  7. 将 tool 消息追加到上下文;
  8. 再次调用模型;
  9. 直到模型给出最终文本或达到停止条件。

用户发起请求→构建请求发送给 LLM→LLM 返回工具调用指令→Agent 内部处理→执行工具→工具返回结果→将结果回传给 LLM→LLM 生成最终回复或继续调用

sequenceDiagram
    participant U as 用户
    participant A as Agent Runtime
    participant M as LLM
    participant T as Tool

    U->>A: 用户消息
    A->>M: messages + tool schemas
    M-->>A: Tool Call(name, arguments, id)
    A->>A: 解析、校验、授权
    A->>T: execute(arguments)
    T-->>A: result / error
    A->>M: 原消息 + assistant tool call + tool result
    M-->>A: 最终文本或下一次 Tool Call
    A-->>U: 最终回复

3. Tool Call ID 维持因果关系

一次响应可能包含多个工具调用。Tool result 必须通过 tool_call_id 与原调用对应,否则 Provider 可能拒绝消息,模型也无法判断哪个结果属于哪个调用。

nanobot 会:

  • 将 Provider 输出转换为 ToolCallRequest;
  • 将 assistant Tool Call 消息加入运行消息;
  • 执行工具;
  • 为每个结果创建 role: tool 消息;
  • 保留 tool_call_id 和工具名;
  • 修复或丢弃不合法的孤立工具消息。

相关源码:

  • nanobot/providers/base.py
    • ToolCallRequest
    • LLMResponse
  • nanobot/agent/runner.py
    • AgentRunner._run_core
    • AgentRunner._execute_tools
    • AgentRunner._drop_orphan_tool_results
    • AgentRunner._backfill_missing_tool_results

4. finish reason 参与决策

不是“响应里出现 tool_calls 字段”就一定执行。

LLMResponse.should_execute_tools 还会检查 Provider 返回的 finish reason 是否允许进入工具执行分支。这样可以避免在语义不一致的响应上盲目执行工具。

不要只看 content

Provider 响应还可能包含 tool calls、finish reason、usage、reasoning、error 和缓存相关 metadata。调试时只打印文本容易漏掉真正的控制信号。

5. 流式输出是增量,不是另一份最终答案

流式接口会逐步返回 delta:

"这"
"是一"
"个回答"

调用方负责把 delta 展示给用户,但 AgentRunner 仍需要知道完整响应何时结束,并据此:

  • 决定是否执行工具;
  • 统计 usage;
  • 保存最终消息;
  • 发布 turn completed;
  • 关闭 SSE 或 WebSocket 流。

nanobot 的流式路径可能使用:

  • on_stream;
  • on_stream_end;
  • SDK stream event;
  • API SSE;
  • Channel delta;
  • WebSocket runtime message。

这些是不同展示表面,共享同一个核心模型请求与 Turn 状态。

6. Tool error 也是模型可消费的数据

许多工具错误并不会直接终止整个 Agent。运行时可以把结构化或可读错误作为 tool result 交还模型,让模型:

  • 修正参数;
  • 换用其他工具;
  • 告知用户无法完成;
  • 在安全违规时停止继续尝试。

但安全错误、明确配置的 fatal tool error 或达到限制时可能结束 Turn。

学习 nanobot 时要区分:

Python 异常
  → 被 ToolRunner 转为可恢复的工具错误
  → 或被分类为 fatal error
  → 最终映射为 stop_reason / final response

7. 上下文并不等于磁盘 Session 原文

下一次模型请求之前,nanobot 可能:

  • 删除孤立 tool result;
  • 为缺失结果补占位;
  • 压缩较早工具输出;
  • 将超大结果写入 workspace,再在上下文中引用;
  • 裁剪历史;
  • 合并连续 user 消息;
  • 添加 runtime metadata。

因此调试时需要分别观察:

  1. Session 中保存的消息;
  2. ContextBuilder 构造的初始 messages;
  3. AgentRunner 每次迭代真正发送给 Provider 的 messages。

对照源码阅读

概念源码
Provider 统一响应nanobot/providers/base.py::LLMResponse
Tool Call 表示nanobot/providers/base.py::ToolCallRequest
是否执行工具LLMResponse.should_execute_tools
模型—工具循环nanobot/agent/runner.py::AgentRunner._run_core
工具查找和校验nanobot/agent/tools/registry.py::ToolRegistry.prepare_call
工具实际执行AgentRunner._execute_tools、_run_tool
Tool result 归一化AgentRunner._normalize_tool_result
流式 Provider 请求AgentRunner._request_model

自检

  1. 为什么模型返回 Tool Call 后通常还需要再次调用模型?
  2. tool_call_id 解决什么问题?
  3. 为什么不能把每个带 tool_calls 的响应都直接执行?
  4. 流式 delta 已经显示给用户后,为什么仍需要最终响应和结束事件?
  5. 为什么 Session 中的历史可能不同于本轮实际发送给模型的历史?

下一步

阅读 源码阅读与调试最低要求。