自己动手写Agent Harness【agent loop】:一百行跑通一个会思考的循环
2026/8/18 9:49:01 网站建设 项目流程

写在前面:系列是为了帮助大家更好的去理解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))

先分清两个概念:turnstep

  • turn = 一次完整交互。用户在键盘上敲一句话,到模型给出最终回复,这是一「轮」。turn 是交互单位。
  • step = 一次模型请求。一次请求加上它带出来的工具调用,是一「步」。step 是模型请求单位。

一个 turn 里可以有多个 step。有工具时:请求 → 调工具 → 再请求,一个 turn 里好几个 step;没有工具时,模型第一步就出文本,turn 立即结束。这份代码里turn()包着step(),注释里写得很明白。

循环至少要有 turn/step 两级边界,才有地方挂超时、压缩、中断——这不是设计洁癖,是 harness 的基建。为什么是两级而不是一级?因为「这轮超时了重来」和「这一步失败了降级」是两种不同的控制。超时是交互级的事:用户等太久了,整轮作废重来。失败是步骤级的事:这一步请求崩了,换个 provider 重试一次,轮次本身不用重开。没有边界,这两件事都无从挂起。

文本回复

工具调用

每转一圈检查

用户消息进来

turn:一次完整交互
边界=打开到结束

step:一次模型请求

组装上下文
system 永远在最前
历史只留最近 MAX_HISTORY

llm.complete 调模型

模型返回什么

turn 结束
等下一个用户输入

maxTurns 停机条件
跑满上限就停

这张图你先记住,后面每一节都是给图里某个节点装细节。

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.js

loop主体一个字都不用改——llm/mock.jsllm/real.js实现同一个签名complete(messages, tools) -> Promise<{ text } | { toolCall }>。这就是 012 说的「模型适配层越薄越稳」:换 provider 一行改。

6. 对照 dsh:ReactLoopAgent

**你以为你写的是一个循环,其实你已经碰到了 dsh 的三个核心设计:

第一个:turn/step 两级边界。dsh 的循环叫ReactLoopAgentpackages/core/agent-loop/src/agent.ts:64),它把工作切成和这一模一样的两个单位。turn()(:246)开循环领 step,step()(:332)构建请求、流式收回复、派发工具调用。你写的ReactLoop类是它的最小投影——连名字都撞了。

第二个:循环读队列,不读数组。你的 history 是数组,直接 push、直接读。dsh 不这么干。它维护一个Inboxpackages/core/agent/src/inbox.ts:25),两条队列:next-turnnext-stepclaim()(:71)领走整批输入。为什么拆两个?step 队列是循环内部推进——一次请求带出的下一步;turn 队列是外部输入——用户进来一次。这一条换来的是输入、注入、中断全变成队列操作,状态机只关心「队列里还有没有活」。分叉、恢复、回放都挂同一事件流上。

第三个:连请求都能改写。你的 messages 在 step 里现场拼。dsh 把这一步拎成buildRequest(:407),而且它要过一道agent/requestwaterfall——任何插件都能改写这次请求。连「模型这次调谁、用什么参数」都是可插拔的。你写的十行注入,是它这层接缝的最小形态。

一张对照表,把你刚写的每一行对应到 dsh 源码:

你写的(step1-loop)dsh 对应行号
ReactLoopReactLoopAgentagent.ts:64
turn()开循环turn()领 stepagent.ts:246
step()一次请求step()请求 + 工具agent.ts:332
step 里现场拼 messagesbuildRequest()组装请求agent.ts:407
history 数组直接读Inbox双队列 next-turn/next-stepinbox.ts:25
history.push / 读数组claim()领整批输入inbox.ts:71
maxTurns for 循环停机条件 + abort 信号turn() 内(:246)

dsh 多做了什么,我压成两句。一是瀑布可改写请求——buildRequest 能被动手术,插件在请求发出前改 provider、改参数。二是事件流驱动——每一步都落 append-only 会话日志,turn/startstep/startassistant/chunk一条条记,模型可见即已记录。这是 010 拆过的东西,也是 04 的正文。你的版本是它的地基,不是它的简化版——方向对,只是还没长高。

7. 结论:先把 loop 跑起来

循环是 harness 的地基。九十多行(标题说一百行,四舍五入),你已经摸到 dsh 的三个核心设计:两级边界、队列驱动、可改写请求。这三个不是 dsh 的发明,是任何会思考的循环都要回答的问题——dsh 只是用源码把答案焊死了,而且给你留了替换的口子。

案例源码:https://download.csdn.net/download/houwenjin/93283537
下篇预告:《自己动手写Agent-【agent tools】:给Harness装手和眼睛——工具注册与执行流水线》

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询