自己动手写Agent Harness【agent loop】:一百行跑通一个会思考的循环 📅 2026/8/18 9:49:03 写在前面系列是为了帮助大家更好的去理解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}]letrunningtruewhile(running){constoutawaitllm.complete(messages,[])messages.push({role:assistant,content:out.text})runningBoolean(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.jsimport{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_HISTORY20// ---------- 最小 loop ----------// 核心就是一个 while模型不主动做事循环是唯一驱动。// - step()一次模型请求把消息发给模型、拿回回复// - turn()一次完整交互用户进来 → 反复 step 直到该停// - 这次没有工具所以 step 最多跑一次就出文本回复turn 就结束了classReactLoop{constructor(llm){this.llmllmthis.history[]// 当前上下文含 system}asyncstep(){// 把 system 历史组成这一次的请求消息constmessages[systemMessage(SYSTEM_PROMPT),...this.history]constoutawaitthis.llm.complete(messages,[])this.history.push(assistantMessage(out))returnout}asyncturn(userText){console.log(\n[user]${userText})this.history.push(userMessage(userText))// 一个 turn 里可能有多步有工具时请求→调工具→再请求。// 没有工具时模型第一步就出文本turn 立即结束。// maxTurns 是循环的停机条件不管模型在干什么跑满上限就停// 这是防失控也防烧钱的第一道闸。constmaxTurns10for(lett0;tmaxTurns;t){constoutawaitthis.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}}// ---------- 跑 ----------constloopnewReactLoop(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 重试一次轮次本身不用重开。没有边界这两件事都无从挂起。文本回复工具调用每转一圈检查用户消息进来turn一次完整交互边界打开到结束step一次模型请求组装上下文system 永远在最前历史只留最近 MAX_HISTORYllm.complete 调模型模型返回什么turn 结束等下一个用户输入maxTurns 停机条件跑满上限就停这张图你先记住后面每一节都是给图里某个节点装细节。4. 上下文注入system prompt 历史消息怎么组装循环有了边界下一个问题每次请求模型看到什么注入策略就两条代码里写死了constSYSTEM_PROMPT你是一个极简 agent。你能思考、能决定做什么但还没有工具。constMAX_HISTORY20然后在step()里现场拼一份请求constmessages[systemMessage(SYSTEM_PROMPT),...this.history]constoutawaitthis.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()里constmaxTurns10for(lett0;tmaxTurns;t){constoutawaitthis.step()if(out.text){console.log([assistant]${out.text})return}}这是防失控的第一道闸同时是成本保护。模型如果在循环里卡住——比如将来有了工具、死循环调工具——跑满 maxTurns 就停。每转一圈都是一次 API 调用没有上限等于烧钱没有上限。注意这道闸挂在哪挂在 turn 的边界上。这就是第三节点题的「基建」——没有两级边界这道闸没处放。换真实模型设三个环境变量就切到llm/real.jsOpenAI 兼容接口零依赖 fetchOPENAI_BASE_URLhttps://api.deepseek.com\OPENAI_API_KEYsk-xxx\OPENAI_MODELdeepseek-chat\nodestep1-loop/index.jsloop主体一个字都不用改——llm/mock.js和llm/real.js实现同一个签名complete(messages, tools) - Promise{ text } | { toolCall }。这就是 012 说的「模型适配层越薄越稳」换 provider 一行改。6. 对照 dshReactLoopAgent**你以为你写的是一个循环其实你已经碰到了 dsh 的三个核心设计第一个turn/step 两级边界。dsh 的循环叫ReactLoopAgentpackages/core/agent-loop/src/agent.ts:64它把工作切成和这一模一样的两个单位。turn():246开循环领 stepstep():332构建请求、流式收回复、派发工具调用。你写的ReactLoop类是它的最小投影——连名字都撞了。第二个循环读队列不读数组。你的 history 是数组直接 push、直接读。dsh 不这么干。它维护一个Inboxpackages/core/agent/src/inbox.ts:25两条队列next-turn和next-stepclaim():71领走整批输入。为什么拆两个step 队列是循环内部推进——一次请求带出的下一步turn 队列是外部输入——用户进来一次。这一条换来的是输入、注入、中断全变成队列操作状态机只关心「队列里还有没有活」。分叉、恢复、回放都挂同一事件流上。第三个连请求都能改写。你的 messages 在 step 里现场拼。dsh 把这一步拎成buildRequest:407而且它要过一道agent/requestwaterfall——任何插件都能改写这次请求。连「模型这次调谁、用什么参数」都是可插拔的。你写的十行注入是它这层接缝的最小形态。一张对照表把你刚写的每一行对应到 dsh 源码你写的step1-loopdsh 对应行号ReactLoop类ReactLoopAgent类agent.ts:64turn()开循环turn()领 stepagent.ts:246step()一次请求step()请求 工具agent.ts:332step 里现场拼 messagesbuildRequest()组装请求agent.ts:407history 数组直接读Inbox双队列 next-turn/next-stepinbox.ts:25history.push / 读数组claim()领整批输入inbox.ts:71maxTurns for 循环停机条件 abort 信号turn() 内:246dsh 多做了什么我压成两句。一是瀑布可改写请求——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装手和眼睛——工具注册与执行流水线》。