写在前面:系列是为了帮助大家更好的去理解Agent Harness基础设施,并不是想重复造轮子,真实开发建议选择一个成熟的SDK或Harness框架,才是最合适的选择~
1. 模型不主动,循环是唯一驱动
一百行,跑通一个会思考的循环。不是调框架,是从零手写。市面上的教程教你怎么用 harness,这篇带你造一个,而且每写一段都翻 dsh 源码对照。
模型不主动,循环是唯一驱动。模型是一个函数——你给它消息,它回你文本,然后它就去睡了。它不会自己醒来做事。让 agent「会思考」的那个东西,是外面套着的循环:发请求、收回复、再发请求。harness 是壳,循环是壳的心脏。
前篇讨论过 Agent Harness 的公共要素,第一条就是 Agent Loop,落点一句:循环至少要有 turn / step 两级边界。本文来探索Agent Loop:一份九十多行、零依赖、能跑的 loop 代码;一套 turn/step 两级边界的判断框架;一张「你写的每一行对应 dsh 哪个文件哪行」的对照表。
2. 最小的循环:while 调模型
先别管真文件,想想如果你第一次写,会写成什么样。
如果只留下循环的本质,十行以内:
// 骨架(示意):先跑通「发消息 → 收回复 → 决定要不要再来」constmessages=[{role:'user',content:userText}]letrunning=truewhile(running){constout=awaitllm.complete(messages,[])messages.push({role:'assistant',content:out.text})running=Boolean(out.toolCall)// 有工具就再来一轮}console.log(messages.at(-1).content)发消息 → 收回复 → 决定要不要再来。循环就这一句话。它能跑,但它缺三样东西。
- 没有边界:一个 while 裸转,模型卡住就永远转下去,你没有任何地方插手。
- 没有上下文注入:system prompt 没地方放,历史越滚越长也没有截断。
- 没有历史管理:messages 一个数组,谁进谁出靠手推。
这三样缺的不是「功能」,是「位置」——循环里没有给它们留位置。下面要解决的,就是给它们腾位置。
3. 拆成 turn 和 step:为什么要有两级边界
先上代码:
// step1-loop/index.js —— 系列第一篇:最小的 agent loop。//// 目标:一个约 100 行的最小 loop,跑通「会思考的循环」。// - turn/step 两级边界:turn=用户进来一次完整交互,step=一次模型请求// - 上下文注入:system prompt + 历史消息滚动// - 停机条件:maxTurns(防失控的第一道闸)// - 零依赖:mock 模型,node 直接跑//// 运行:node step1-loop/index.js// 换真实模型:OPENAI_BASE_URL / OPENAI_API_KEY / OPENAI_MODEL 环境变量import{MockLLM}from'../llm/mock.js'import{RealLLM}from'../llm/real.js'// ---------- 模型适配层(薄) ----------// 用环境变量决定用真实模型还是 mock。这一层就是「适配器」——换 provider// 只改这一行,loop 主体完全不知道底下是谁(012 讲的「模型适配层越薄越稳」)。functionmakeLLM(){if(process.env.OPENAI_API_KEY){returnnewRealLLM({baseURL:process.env.OPENAI_BASE_URL,apiKey:process.env.OPENAI_API_KEY,model:process.env.OPENAI_MODEL,})}returnnewMockLLM()}// ---------- 上下文注入 ----------// SYSTEM_PROMPT 常驻,历史消息滚动;注入策略就两条:// 1. system 永远在最前// 2. 历史只保留最近 MAX_HISTORY 条(最简陋的截断,避免上下文无限膨胀)constSYSTEM_PROMPT='你是一个极简 agent。你能思考、能决定做什么,但还没有工具。'constMAX_HISTORY=20// ---------- 最小 loop ----------// 核心就是一个 while:模型不主动做事,循环是唯一驱动。// - step():一次模型请求,把消息发给模型、拿回回复// - turn():一次完整交互(用户进来 → 反复 step 直到该停)// - 这次没有工具,所以 step 最多跑一次就出文本回复,turn 就结束了classReactLoop{constructor(llm){this.llm=llmthis.history=[]// 当前上下文(含 system)}asyncstep(){// 把 system + 历史组成这一次的请求消息constmessages=[systemMessage(SYSTEM_PROMPT),...this.history]constout=awaitthis.llm.complete(messages,[])this.history.push(assistantMessage(out))returnout}asyncturn(userText){console.log(`\n[user]${userText}`)this.history.push(userMessage(userText))// 一个 turn 里可能有多步(有工具时:请求→调工具→再请求)。// 没有工具时,模型第一步就出文本,turn 立即结束。// maxTurns 是循环的停机条件:不管模型在干什么,跑满上限就停,// 这是防失控(也防烧钱)的第一道闸。constmaxTurns=10for(lett=0;t<maxTurns;t++){constout=awaitthis.step()if(out.text){console.log(`[assistant]${out.text}`)return}console.log('[assistant] (tool call — 本篇还没有工具,这不该发生)')}console.log(`[assistant] 达到 maxTurns=${maxTurns},循环停机`)}}// ---------- 消息构造小工具 ----------functionsystemMessage(content){return{role:'system',content}}functionuserMessage(content){return{role:'user',content}}functionassistantMessage(out){returnout.toolCall?{role:'assistant',toolCall:out.toolCall}:{role:'assistant',content:out.text}}// ---------- 跑 ----------constloop=newReactLoop(makeLLM())awaitloop.turn('你好,你是谁?')awaitloop.turn('我在学搭一个 agent harness。')console.log('\n—— 本轮上下文历史 ——')console.log(JSON.stringify(loop.history.map((m)=>m.role),null,0))先分清两个概念:turn和step。
- turn = 一次完整交互。用户在键盘上敲一句话,到模型给出最终回复,这是一「轮」。turn 是交互单位。
- step = 一次模型请求。一次请求加上它带出来的工具调用,是一「步」。step 是模型请求单位。
一个 turn 里可以有多个 step。有工具时:请求 → 调工具 → 再请求,一个 turn 里好几个 step;没有工具时,模型第一步就出文本,turn 立即结束。这份代码里turn()包着step(),注释里写得很明白。
循环至少要有 turn/step 两级边界,才有地方挂超时、压缩、中断——这不是设计洁癖,是 harness 的基建。为什么是两级而不是一级?因为「这轮超时了重来」和「这一步失败了降级」是两种不同的控制。超时是交互级的事:用户等太久了,整轮作废重来。失败是步骤级的事:这一步请求崩了,换个 provider 重试一次,轮次本身不用重开。没有边界,这两件事都无从挂起。
这张图你先记住,后面每一节都是给图里某个节点装细节。
4. 上下文注入:system prompt + 历史消息怎么组装
循环有了边界,下一个问题:每次请求,模型看到什么?
注入策略就两条,代码里写死了:
constSYSTEM_PROMPT='你是一个极简 agent。你能思考、能决定做什么,但还没有工具。'constMAX_HISTORY=20然后在step()里现场拼一份请求:
constmessages=[systemMessage(SYSTEM_PROMPT),...this.history]constout=awaitthis.llm.complete(messages,[])注入点在哪?在 step 的入口。每次模型请求,都是「system + 当前 history」现场拼一份,不缓存、不共享。
哪些进哪些不进,我压成三条:system 常驻,永远在最前;用户输入进,模型回复进;工具结果将来也进(那是 02 的事)。MAX_HISTORY是最简陋的截断——历史只留最近 20 条,防止上下文无限膨胀。真实 harness 的截断策略复杂得多(按 token 算、按重要性压缩),但雏形就是这一行。
history 是循环自己维护的。追加发生在step()里:调完模型,assistantMessage(out)压进 history,下一次 step 就能看到。这就是闭环——模型说的话,下一轮它自己能看到。
这里埋着 dsh 的一个核心设计:dsh 把「组装请求」单独拎成一个函数buildRequest,而且它还能被插件改写。第 6 节对照时兑现。
5. 跑通它:真实模型调用
代码敲完了,跑。默认是 mock 模型,不需要 API key,输出是确定性的——你在本机能跑出和我一模一样的结果:
$ node step1-loop/index.js [user] 你好,你是谁? [assistant] (mock)收到:你好,你是谁? [user] 我在学搭一个 agent harness。 [assistant] (mock)收到:我在学搭一个 agent harness。 —— 本轮上下文历史 —— ["user","assistant","user","assistant"]看到(mock)收到:这个签名了吗?它来自llm/mock.js的回复规则,最后一行:
// llm/mock.js 的回复规则(最后一行)return{text:`(mock)收到:${text.slice(0,40)}`}mock 是个假模型,它只会复读。但循环是真的——两轮 turn,历史正确累积成 4 条user/assistant/user/assistant。
这证明的不是模型聪明,是循环在正确地转。用户消息进了历史,模型回复进了历史,上下文在长,停机条件在工作。
maxTurns 停机条件在turn()里:
constmaxTurns=10for(lett=0;t<maxTurns;t++){constout=awaitthis.step()if(out.text){console.log(`[assistant]${out.text}`)return}}这是防失控的第一道闸,同时是成本保护。模型如果在循环里卡住——比如将来有了工具、死循环调工具——跑满 maxTurns 就停。每转一圈都是一次 API 调用,没有上限等于烧钱没有上限。注意这道闸挂在哪:挂在 turn 的边界上。这就是第三节点题的「基建」——没有两级边界,这道闸没处放。
换真实模型,设三个环境变量就切到llm/real.js(OpenAI 兼容接口,零依赖 fetch):
OPENAI_BASE_URL=https://api.deepseek.com\OPENAI_API_KEY=sk-xxx\OPENAI_MODEL=deepseek-chat\nodestep1-loop/index.jsloop主体一个字都不用改——llm/mock.js和llm/real.js实现同一个签名complete(messages, tools) -> Promise<{ text } | { toolCall }>。这就是 012 说的「模型适配层越薄越稳」:换 provider 一行改。
6. 对照 dsh:ReactLoopAgent
**你以为你写的是一个循环,其实你已经碰到了 dsh 的三个核心设计:
第一个:turn/step 两级边界。dsh 的循环叫ReactLoopAgent(packages/core/agent-loop/src/agent.ts:64),它把工作切成和这一模一样的两个单位。turn()(:246)开循环领 step,step()(:332)构建请求、流式收回复、派发工具调用。你写的ReactLoop类是它的最小投影——连名字都撞了。
第二个:循环读队列,不读数组。你的 history 是数组,直接 push、直接读。dsh 不这么干。它维护一个Inbox(packages/core/agent/src/inbox.ts:25),两条队列:next-turn和next-step,claim()(:71)领走整批输入。为什么拆两个?step 队列是循环内部推进——一次请求带出的下一步;turn 队列是外部输入——用户进来一次。这一条换来的是输入、注入、中断全变成队列操作,状态机只关心「队列里还有没有活」。分叉、恢复、回放都挂同一事件流上。
第三个:连请求都能改写。你的 messages 在 step 里现场拼。dsh 把这一步拎成buildRequest(:407),而且它要过一道agent/requestwaterfall——任何插件都能改写这次请求。连「模型这次调谁、用什么参数」都是可插拔的。你写的十行注入,是它这层接缝的最小形态。
一张对照表,把你刚写的每一行对应到 dsh 源码:
| 你写的(step1-loop) | dsh 对应 | 行号 |
|---|---|---|
ReactLoop类 | ReactLoopAgent类 | agent.ts:64 |
turn()开循环 | turn()领 step | agent.ts:246 |
step()一次请求 | step()请求 + 工具 | agent.ts:332 |
| step 里现场拼 messages | buildRequest()组装请求 | agent.ts:407 |
| history 数组直接读 | Inbox双队列 next-turn/next-step | inbox.ts:25 |
| history.push / 读数组 | claim()领整批输入 | inbox.ts:71 |
| maxTurns for 循环 | 停机条件 + abort 信号 | turn() 内(:246) |
dsh 多做了什么,我压成两句。一是瀑布可改写请求——buildRequest 能被动手术,插件在请求发出前改 provider、改参数。二是事件流驱动——每一步都落 append-only 会话日志,turn/start、step/start、assistant/chunk一条条记,模型可见即已记录。这是 010 拆过的东西,也是 04 的正文。你的版本是它的地基,不是它的简化版——方向对,只是还没长高。
7. 结论:先把 loop 跑起来
循环是 harness 的地基。九十多行(标题说一百行,四舍五入),你已经摸到 dsh 的三个核心设计:两级边界、队列驱动、可改写请求。这三个不是 dsh 的发明,是任何会思考的循环都要回答的问题——dsh 只是用源码把答案焊死了,而且给你留了替换的口子。
案例源码:https://download.csdn.net/download/houwenjin/93283537
下篇预告:《自己动手写Agent-【agent tools】:给Harness装手和眼睛——工具注册与执行流水线》。