Agent Loop
One Loop Is All You Need — 一个循环 + 一个工具 = 一个 Agent。
最小可用 Agent 就是"调用模型 → 跑工具 → 回传结果"的循环。
问题
你问大模型:"帮我列出当前目录下的文件,然后运行 test.py。"
模型能输出一条 bash 命令,但输出完了它就停了。它不会自己执行命令,也不会看到结果后继续推理。
你可以手动把命令复制到终端跑一遍,再把输出粘贴回对话框,让它接着干。下一个命令出来,你再跑一遍、再贴回去。
每一个来回,你都在做那个中间层。把它自动化,就是这一课要做的事。
解决方案
一个 while true 循环:模型调用工具就继续,不调用就停。整个过程只有两个信号:
| 信号 | 含义 | 循环动作 |
|---|---|---|
tool_calls 存在 |
模型举手说"我要用工具" | 执行 → 结果喂回去 → 继续 |
tool_calls 不存在 |
模型说"我做完了" | 退出循环 |
工作原理
第 1 步:把用户的问题作为第一条消息。
const messages = [{ role: "user", content: query }];
第 2 步:把消息和工具定义一起发给 LLM(DeepSeek 使用 OpenAI 兼容接口)。
const res = 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-chat", messages, tools }),
});
const data = await res.json();
第 3 步:追加模型回答,检查它是否调了工具。没调 → 结束。
const msg = data.choices[0].message;
messages.push(msg);
if (!msg.tool_calls) return;
第 4 步:执行模型要求的工具,收集结果。
for (const call of msg.tool_calls) {
const result = await execute(call.function.name, JSON.parse(call.function.arguments));
messages.push({ role: "tool", tool_call_id: call.id, content: String(result) });
}
组装为一个完整函数(s01_agent_loop.mjs,约 30 行):
const API = "https://api.deepseek.com/chat/completions";
async function runBash(command) {
const { execSync } = await import("node:child_process");
try { return execSync(command, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }); }
catch (e) { return `EXIT ${e.status}: ${e.stderr}`; }
}
const TOOLS = [{
type: "function",
function: {
name: "bash",
description: "在本地 shell 中执行命令并返回输出",
parameters: { type: "object", properties: { command: { type: "string" } }, required: ["command"] },
},
}];
const execute = (name, args) => name === "bash" ? runBash(args.command) : `unknown tool: ${name}`;
export async function agentLoop(query) {
const messages = [{ role: "user", content: query }];
while (true) {
const res = await fetch(API, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.DEEPSEEK_API_KEY}` },
body: JSON.stringify({ model: "deepseek-chat", messages, tools: TOOLS }),
});
const data = await res.json();
const msg = data.choices[0].message;
messages.push(msg);
if (!msg.tool_calls) return;
for (const call of msg.tool_calls) {
const output = await execute(call.function.name, JSON.parse(call.function.arguments));
messages.push({ role: "tool", tool_call_id: call.id, content: output });
}
}
}
不到 30 行,这就是最小可运行的 agent harness 内核。它不是智能本身,而是让模型能持续行动的最小运行框架:模型负责决策(要不要调工具、调哪个),harness 负责执行(调了就跑、结果喂回去)。后面 19 课都在这个循环上叠加机制,循环本身始终不变。
试一下
运行:
export DEEPSEEK_API_KEY=sk-...
node s01_agent_loop.mjs
试试这些 prompt:
- "列出当前目录下的所有文件"
- "创建一个 hello.py,内容是打印 Hello, World!"
- "当前在哪个 git 分支上?"
观察重点:模型什么时候调用工具(循环继续),什么时候不调用(循环结束)?注意模型可能会一次请求调用多个工具——这为 s02 的分发表埋下伏笔。
接下来
现在模型手里只有 bash 一个工具。读文件要 cat,写文件要 echo ... >,找文件要 find——又丑又容易出错。
s02 Tool Use → 给它 5 个真正的工具,会发生什么?
以下内容基于仓库中 packages/core 的核查。核心差异只有一个:DSH 的循环不是 30 行,而是事件驱动的一个 turn/step 生命周期——但它的本质和你的 while true 一模一样。
一、循环在哪
- 循环驱动者:
packages/core/agent-loop/src/agent.ts—— 仓库里唯一的"具体循环逻辑"包(ctx.agentLoop)。 - 循环的公共契约:
packages/core/agent/src/index.ts——Agent接口、注册表、agent/*事件。 - 可运行的默认组合:
packages/examples/agent-spine-demo。
二、一个 step 是什么
在 DSH 里,一个 step = 一次模型请求 + 它调用的工具;一个 turn = 零个或多个 step,从第一个输入被认领开始,到不再欠任何输入为止。你的 while true 的每一轮迭代,就是一个 step。
turn/start
claim 输入 → 组装 prompt 与工具 schema → agent/pre-step(可拦截)
step/start
从会话日志派生模型历史 → agent/request → llm/stream → assistant/message
tool/call* → tools/pre-execute → tools/execute → tools/post-execute → tool/result*
step/end
(还有输入待处理)→ 下一个 step
turn/end
三、两个关键差异
| 你的最小循环 | DSH 的真实循环 |
|---|---|
messages 数组直接累积 |
所有事实先写入会话事件日志(SessionEvent),模型历史从日志派生(deriveMessages()) |
| 只有一个退出条件(不调工具就停) | 多条退出/继续路径:输入耗尽、拦截拒绝、错误恢复、token 预算、turn 上限等 |
Model-visible ⟺ logged:任何到达模型请求的内容都必须能从会话日志重建,这是 DSH 的一条运行时不变量。你的 messages 数组在 DSH 里被提升成了持久化的事实流——这正是 s09 的主题。
一句话:DSH 的整个 agent-loop 就是你的 30 行 while true 的生产级形态。先理解核心循环,后面的一切自然展开。