第一次真正调用 LLM 后,我看清了 Runtime、SDK 和模型的边界

昨天我写了一篇文章:《从 messages 看懂 Agent 的工具调用循环:Runtime 如何衔接模型与工具》

那篇文章最后,我给自己留下了一个下一步:

把教学 Runtime 中的抽象链路放进真实模型 API,观察一次真正的请求和响应。

因为之前使用的 minimal-agent-runtime 虽然能够运行和测试,但它的 model.decide() 是模拟的。模型返回什么,实际上由测试代码提前写好。

今天,我第一次用 Node.js 真正连接了 DeepSeek API。

我原本以为,这一步只是把模拟的 model.decide() 换成一个 SDK 调用。真正顺着请求和响应走过一遍后,我才发现自己对"Runtime 调用模型"这句话的理解还是太笼统。

我原本以为:Runtime 通过 SDK 把 messages 发给模型

在开始写请求之前,我脑中的链路是:

text 复制代码
Runtime 组装 messages
→ SDK 把 messages 发给服务端
→ LLM 返回 assistant message

这个理解大体上没有错,但它把几个不同层次压成了一个词。

当我继续追问"Runtime 到底怎样发请求"时,问题就出现了:Runtime 自己并没有一种叫"连接 LLM"的特殊能力。真正运行 JavaScript、执行网络请求的是 Node.js;真正决定发送哪些 messages 的,是应用代码;SDK只是把模型 API 的调用方式封装起来。

如果不用 SDK,同样可以直接使用 fetch

js 复制代码
const messages = [
  {
    role: "user",
    content: "请只回复:API 调用成功",
  },
];

const httpResponse = await fetch(
  "https://api.deepseek.com/chat/completions",
  {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.DEEPSEEK_API_KEY}`,
    },
    body: JSON.stringify({
      model: "deepseek-v4-flash",
      messages,
      stream: false,
      thinking: { type: "disabled" },
    }),
  },
);

const response = await httpResponse.json();

这次我才把原来笼统的 Runtime 继续拆开:

text 复制代码
应用代码决定请求内容和 messages
→ Node.js 执行程序和网络请求
→ SDK 或 fetch 按 API 协议发送请求
→ 模型服务完成鉴权、路由和响应包装
→ LLM 根据上下文生成内容

SDK可以让连接、输入和输出更统一,但它不自动拥有会话。保存历史、决定下一轮发送什么,仍然是应用或 Agent Runtime 的工作。

第一次请求没有调用到 LLM

我的第一次真实请求返回了 401

我一开始关心的是模型会返回什么,实际却连模型都没有调用到。原因是 Node.js 程序没有正确取得 API Key,请求在 DeepSeek 服务端的鉴权阶段就被拦截了。

这次失败反而让我第一次通过真实错误看清了调用边界:

text 复制代码
Node.js 发出请求
→ 模型 API 服务鉴权失败
→ 请求结束
→ LLM 没有被调用

所以,以后看到模型 API 报错,不能笼统地说"LLM 出错了"。

错误可能发生在:

  • 本地程序没有正确读取配置;
  • 网络请求没有成功到达服务端;
  • API Key 鉴权失败;
  • 请求体不符合接口要求;
  • 服务端没有成功调用模型;
  • 模型已经调用,但生成结果不符合预期。

这些错误都可能出现在同一条调用链上,但它们不是同一层的问题。

第一次看到真实的模型响应

修正 API Key 的读取后,请求成功了。

我要求模型只回复"API 调用成功",真正读取内容的位置是:

js 复制代码
response.choices[0].message.content

这次响应的核心内容可以简化为:

js 复制代码
{
  choices: [
    {
      message: {
        role: "assistant",
        content: "API 调用成功",
      },
      finish_reason: "stop",
    },
  ],
  usage: {
    prompt_tokens: 12,
    completion_tokens: 4,
    total_tokens: 16,
  },
}

以前在教学 Runtime 中,我自己定义过类似这样的结果:

ts 复制代码
{
  type: "final",
  content: "API 调用成功",
}

但真实 DeepSeek API 不会返回我在教学代码中定义的 final。它返回的是 Chat Completion 对象,最终内容位于 choices[0].message.content,停止原因位于 choices[0].finish_reason

这也让我重新区分了两件事:

  • assistant message 是模型在对话中生成的消息;
  • choicesfinish_reasonusage 是 API 服务包装在消息外面的响应信息。

LLM 负责生成内容,但响应对象长什么样,是 API 协议的一部分。

DeepSeek 当前的Chat Completion 文档也把非流式响应定义为一个完整的 Chat Completion 对象,其中包含 choicesmessagefinish_reasonusage

多轮对话是谁保存的?

第一次请求成功后,我又发起了第二轮:

js 复制代码
const messages = [
  {
    role: "user",
    content: "请只回复:API 调用成功",
  },
  {
    role: "assistant",
    content: "API 调用成功",
  },
  {
    role: "user",
    content: "再回复一次",
  },
];

这里不是只发送"再回复一次",而是把上一轮的 userassistant 消息一起重新发送。

我昨天通过教学代码理解了"模型只能看到本次请求实际传给它的内容"。今天真实调用后,这个判断得到了进一步验证:API不会因为我在同一个程序里请求过一次,就自动替我补齐上一轮上下文。

多轮对话的连续性来自:

text 复制代码
应用保存历史消息
→ 下一轮重新组装 messages
→ 再次发送完整上下文

第二次请求中,prompt_tokens 从 12 增加到了 23。

上下文变多,输入 Token 也随之增加。这就是多轮对话成本会继续上升的一个直接原因:模型每一轮都需要重新处理这次请求中携带的上下文。

stoplength 让我看到了两种结束

第一次请求的:

js 复制代码
response.choices[0].finish_reason

返回的是:

text 复制代码
stop

我最开始只能盲猜它表示"本轮对话结束"。结合真实响应和官方文档后,我确认它表示模型到达了自然停止点,或者遇到了请求指定的停止序列。

随后,我把 max_tokens 设为 1,并要求模型生成一段更长的内容。结果只返回了一个 Token,finish_reason 变成:

text 复制代码
length

这次结果说明:即使 HTTP 请求成功,也确实拿到了 message.content,内容仍然可能因为 Token 上限而被截断。

所以应用不能只读取:

js 复制代码
response.choices[0].message.content

还应该检查:

js 复制代码
response.choices[0].finish_reason

否则,程序可能把一段没有生成完的内容当成完整答案继续处理。

DeepSeek 当前文档还列出了 content_filtertool_callsinsufficient_system_resource 等结束原因,但今天我只实际验证了 stoplength

非流式返回,不等于每次生成相同内容

这次请求使用了:

js 复制代码
stream: false

它表示程序等待模型生成完成后,一次性接收整个响应,而不是逐段接收流式数据。

但这不会让 LLM 变成一个普通的确定性函数。

LLM 每生成一个 Token,都会基于当前上下文计算候选 Token 的概率。温度等参数会影响随机程度,但相同的 messages 并不天然保证每次得到逐字相同的 content

如果目标特别明确,例如:

text 复制代码
请只回复:API 调用成功

模型更可能返回相同内容,但"更可能一致"不等于接口承诺"一定一致"。

这也意味着,应用不能把普通自然语言结果当成永远稳定的程序常量。如果后续代码依赖固定结构,还需要结构化输出、Schema 校验、失败处理和必要的重试策略。

我今天真正学会了什么?

前几天,我已经知道 Runtime 会组装 messages、调用模型并维护循环,但这个认识仍然停留在教学代码中。

今天第一次连接真实 LLM 后,我把这条链路继续拆细了:

text 复制代码
应用代码组装 messages
→ Node.js 执行程序
→ SDK 或 fetch 发送请求
→ API 服务鉴权并调用模型
→ LLM 概率生成内容
→ API 服务包装响应
→ 应用读取结果并决定下一步

我现在更清楚:SDK保证的是连接和接口适配,不会替应用维护完整对话;LLM负责生成内容,不会替Runtime保存历史;stream: false 决定的是返回方式,不决定生成结果是否一致。

今天最值得留下的,不是记住 DeepSeek 的某几个字段,而是当一次模型调用出问题时,我终于知道应该继续追问:

text 复制代码
是应用组装错了 messages?
是 Node.js 没有正确发送请求?
是 SDK 或接口参数不匹配?
是服务端鉴权失败?
还是 LLM 已经生成,但结果不符合程序预期?

下一步,我会继续学习结构化输出和 Schema 校验:当模型返回的内容不符合预期时,应该在哪一层拒绝、修复、重试或终止。

相关推荐
用户593608741409 小时前
AI Harness 最佳实践
llm·agent
Esaka_Forever19 小时前
LLM 与原生代码分工核心思路整理(精简 + 拓展落地版)
llm
To_OC21 小时前
跑了个最简 RAG demo 后,我终于搞懂向量检索那堆分数到底啥意思
人工智能·llm·agent
JouYY1 天前
大模型底层学习(一)-Transformer 架构总览
架构·llm·agent
@atweiwei1 天前
Langchainrust:中LLM-as-a-Judge,用 Rust 实现一套 LLM 评估系统
开发语言·后端·ai·rust·llm·rag
一起努力啊~1 天前
DataWhale组队学习笔记--llm-algo-leetcode(三)
笔记·学习·llm
AINative软件工程1 天前
LLM 并发工具调用的 5 个生产陷阱:幂等性、竞态与失败补偿
llm·agent·ai编程