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": "."
}
}这只表示模型希望调用工具。运行时还必须:
- 找到对应工具;
- 解析参数;
- 根据 schema 校验参数;
- 检查工具是否允许执行;
- 执行工具;
- 把结果与正确的 Tool Call ID 关联;
- 将 tool 消息追加到上下文;
- 再次调用模型;
- 直到模型给出最终文本或达到停止条件。
用户发起请求→构建请求发送给 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.pyToolCallRequestLLMResponse
nanobot/agent/runner.pyAgentRunner._run_coreAgentRunner._execute_toolsAgentRunner._drop_orphan_tool_resultsAgentRunner._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 response7. 上下文并不等于磁盘 Session 原文
下一次模型请求之前,nanobot 可能:
- 删除孤立 tool result;
- 为缺失结果补占位;
- 压缩较早工具输出;
- 将超大结果写入 workspace,再在上下文中引用;
- 裁剪历史;
- 合并连续 user 消息;
- 添加 runtime metadata。
因此调试时需要分别观察:
- Session 中保存的消息;
- ContextBuilder 构造的初始 messages;
- 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 |
自检
- 为什么模型返回 Tool Call 后通常还需要再次调用模型?
tool_call_id解决什么问题?- 为什么不能把每个带
tool_calls的响应都直接执行? - 流式 delta 已经显示给用户后,为什么仍需要最终响应和结束事件?
- 为什么 Session 中的历史可能不同于本轮实际发送给模型的历史?
下一步
阅读 源码阅读与调试最低要求。