LinOnward / Agent TutorialAgent Tutorial
章节4 / 18
教程整体目标

通过逐章可运行的增量,理解 Agent 的模型、上下文、任务状态、规划、工具、Skills 与 Agent Loop 等关键组件,并从零构建一个由 Harness 约束、不依赖 Agent 框架的可用 Agent。

03

最小 Agent Loop

先用最少状态和停止条件完成可观察、可取消的模型循环。

本页目录
本章任务让受控 Harness 在预算内完成“解释 package.json 的作用”,并返回明确停止原因。
章节目标用最少状态实现第一个可观察、可取消、有停止条件且可测试的 Agent Loop。
开始之前

已经有 ModelRequest 和可替换的 Model 接口。

本章涉及文件
  • src/types.ts
  • src/harness.ts
  • src/index.ts
  • tests/harness.test.ts

这是教程的第一个最小 Agent Loop。它暂时只有内存状态和文本出口;模型只提出下一步意图,是否继续、允许多少步、如何记录结果以及何时停止,都由 Harness 决定。后面的状态章会在基础仓库闭环完成后,把这份最小记录升级成可恢复、可审计的状态机。

Step 1划清 Harness 的职责

先固定边界,避免把整个应用写进一个循环:

  • CLI:解析参数、装配依赖、设置退出码;
  • Context Builder:从状态生成本轮 ModelRequest
  • Model Adapter:调用模型并把厂商响应转成领域对象;
  • Planner:进阶阶段再加入;最小 Loop 不要求先构造复杂计划;
  • Harness:推进状态机、执行停止策略、调度工具;
  • Tool Executor:校验、授权、超时、执行与序列化;
  • Reporter:输出最终答案和验证证据。

模型不能直接访问文件系统,也不能自己把状态标记为完成。它只能返回文本或工具调用建议。

Step 2定义运行契约

src/types.ts 加入明确的输入、输出和停止原因。上限应由调用方传入,测试时才能把它设得很小。

typescript
import type { Model } from "./model.js";export interface HarnessOptions {  cwd: string;  maxSteps: number;  model: Model;  signal?: AbortSignal;  onEvent?: (event: RuntimeEvent) => void;}export interface MinimalAgentState {  runId: string;  task: string;  status: "running" | "completed" | "failed" | "cancelled";  modelSteps: number;}export interface StopReasonMap {  final_answer: true;  max_steps: true;  max_tool_calls: true;  cancelled: true;  model_error: true;  invalid_model_output: true;}export type StopReason = keyof StopReasonMap;export interface AgentResult {  status: MinimalAgentState["status"];  answer: string;  stopReason: StopReason;  state: MinimalAgentState;}export type RuntimeEvent =  | { type: "run_started"; runId: string }  | { type: "model_started"; step: number }  | { type: "model_completed"; step: number }  | { type: "run_stopped"; reason: StopReason };

这里暂时命名为 RuntimeEvent,避免与状态章将要持久化的审计事件混为一谈。事件回调不应改变运行语义;它只负责日志、UI 流式更新或 tracing。

Step 3实现第一个受控循环

创建 src/harness.ts。本章尚未接入工具,所以模型必须给出非空最终文本;下一章会在同一循环中加入工具分支。

typescript
import { randomUUID } from "node:crypto";import { buildModelRequest } from "./context.js";import type { AgentResult, HarnessOptions, MinimalAgentState, StopReason } from "./types.js";export async function runAgent(  task: string,  options: HarnessOptions,): Promise<AgentResult> {  const state: MinimalAgentState = {    runId: randomUUID(),    task,    status: "running",    modelSteps: 0,  };  options.onEvent?.({ type: "run_started", runId: state.runId });  try {    for (let step = 1; step <= options.maxSteps; step += 1) {      if (options.signal?.aborted) return stop(state, "cancelled");      const request = buildModelRequest({        task: state.task,        cwd: options.cwd,        toolNames: [],        sources: [],      });      options.onEvent?.({ type: "model_started", step });      const answer = (await options.model.generate(request)).trim();      options.onEvent?.({ type: "model_completed", step });      state.modelSteps = step;      if (!answer) return stop(state, "invalid_model_output");      state.status = "completed";      return { status: "completed", answer, stopReason: "final_answer", state };    }    return stop(state, "max_steps");  } catch {    const reason = options.signal?.aborted ? "cancelled" : "model_error";    return stop(state, reason);  }}function stop(state: MinimalAgentState, reason: StopReason): AgentResult {  state.status = reason === "cancelled" ? "cancelled" : "failed";  return { status: state.status, answer: "", stopReason: reason, state };}

Step 4统一处理失败与取消

网络超时、限流和无效响应不能变成未处理异常。只在模型适配器中重试可恢复错误,并使用有上限的指数退避;Harness 负责把最终失败映射成稳定的 stopReason

至少测试这些分支:模型抛错、返回空文本、达到步骤上限、收到 AbortSignal。日志可以记录错误类型、请求 ID 和重试次数,但不要记录 API Key 或未脱敏的完整上下文。

Step 5让 CLI 只负责装配

更新 src/index.ts。入口负责信号处理和退出码,不了解循环细节。

typescript
import { runAgent } from "./harness.js";import { createModel } from "./model-factory.js";const task = process.argv.slice(2).join(" ").trim();if (!task) throw new Error("请提供任务");const controller = new AbortController();process.once("SIGINT", () => controller.abort());const model = createModel();const result = await runAgent(task, {  cwd: process.cwd(),  maxSteps: 12,  model,  signal: controller.signal,  onEvent: (event) => console.error(JSON.stringify(event)),});console.log(result.answer);process.exitCode = result.status === "completed" ? 0 : 1;