产品骨架 v01:CLI 里的完整 Loop

产品骨架 v01:CLI 里的完整 Loop

从第 02 章到第 13 章,我们一直在拆零件:裸调 HTTP、流式接收、Provider 抽象、结构化输出、Tool Calling、最小 Agent Loop、工具系统、上下文工程、持久层、记忆、范式、评估:每一章一个自包含 demo,像工具箱里一排排摆好的扳手。

但零件不是产品。把十二个 demo 摆在一起,你仍然没有一个「能用」的 Agent:demo 之间参数各写各的、没有统一的入口、没有日志、没有退出处理,更没有一个可以长期演进的代码骨架。从本章开始,我们进入阶段二·装配整机:一个主线产品(mini 全栈 Agent,CLI 是第一交互面)从 v01 起步,ch15–ch23 每一章在它上面加一个能力,直到它长成 Node 后端 + SQLite 持久层 + Web 前端的小型产品。

本章是这个产品的 v01 快照:产品骨架。我们不发明任何新原理,只把阶段一的零件组装起来,并补上「产品」缺的三样东西:配置日志分层

本章目标

读完本章并做完配套练习后,你应该能够:

概念与动机:为什么「零件」到「产品」需要骨架

先把「不装骨架」的后果演一遍。假设我们把第 07 章的 demo 直接改大,做成一个「什么都能问」的命令行工具:

// 反面教材:把 demo 写大的第一版
const BASE_URL = 'http://localhost:3217';           // 硬编码
const MODEL = 'mock-model';
const temperature = 0.7;

async function main() {
  // 每一处都在重复「读输入 → 调模型 → 打印」
  const question = process.argv[2];
  const messages = [{ role: 'user', content: question }];
  const res = await fetch(`${BASE_URL}/v1/chat/completions`, { /* ... */ });
  const answer = (await res.json()).choices[0].message.content;
  console.log(answer);
}

这个版本能跑,但你已经能闻到产品化的要求从四面八方冒出来:

  1. 拼装:真实对话要走完整循环(think → act → observe)、要工具、要 Provider、要停止条件。把它们手拉手接起来,是第一个要解决的事,而且接法必须是可持续的,因为 ch15 还要往这个接缝里塞东西。
  2. 配置:模型名、API key、地址、温度、循环上限……demo 里全是硬编码。换一个供应商就要改源码。产品要求「运行参数可变、代码不变」:环境变量 + 默认值是最朴素也最标准的答案(12-Factor App 的配置规范)。
  3. 日志:用户输入了一句话,Agent 内部想了什么、调了哪个工具、工具返回了什么、最后为什么停下来,这些 trace 不打印出来,用户面对的就是一个黑盒(或者更糟:看起来像卡死)。日志要成为产品的一等公民,但日志属于界面,不该污染核心逻辑。
  4. 分层:这是最本质的一条。上面三点如果全写在一个文件里,功能也能用,但下一次改动(加流式、加权限、加存储)就要在屎山上再叠一层。产品必须回答「这段代码属于哪个层」,并且让依赖方向单向:界面依赖 core,core 不认识界面。

这四条,正是本章骨架的内容。Claude Code 那 1700 多行的主循环之所以能叠加十几个保护机制而不散架,靠的也是同样的骨架化:shareAI-lab/learn-claude-code 的 s01 把这层东西叫做 harness 层(模型与真实世界之间的第一道连接):它自己不是智能,而是让模型能持续行动的最小运行框架(learn-claude-code · s01: Agent Loop)。我们今天装的,就是自己产品的 harness。

手写实现:产品骨架

产品形态与分层设计

v01 的产品形态一句话:一个能对话、能查天气/时间/算数、能看清自己在干什么的 CLI Agent。代码组织如下(后续九章的演进基础):

flowchart TD
  subgraph interface["界面层 · interface"]
    CLI["lib/cli.mjs<br/>组合根:读 stdin → 调 core → 打印输出 + 日志"]
  end
  subgraph core["核心层 · core(纯函数 / 依赖注入)"]
    CFG["lib/config.mjs<br/>配置:环境变量 + 默认值"]
    PROV["lib/provider.mjs<br/>传输:内部消息 ↔ 厂商 wire 格式"]
    TOOLS["lib/tools.mjs<br/>工具:注册表 + schema 生成 + 错误协议"]
    LOOP["lib/loop.mjs<br/>主循环:think → act → observe"]
  end
  MOCK["mock LLM / 真实供应商<br/>(HTTP)"]

  CLI --> CFG
  CLI --> PROV
  CLI --> TOOLS
  CLI --> LOOP
  LOOP -->|callLlm 注入| PROV
  LOOP -->|registry 注入| TOOLS
  PROV -->|fetch| MOCK
  CFG -. 读取 .-> ENV["环境变量"]

几个关键点:

一、配置层:lib/config.mjs

产品第一个文件只有十行:把「环境变量 + 默认值」收敛成一个配置对象。

export function loadConfig(env = process.env) {
  return {
    model: env.MODEL ?? 'mock-model',
    apiKey: env.API_KEY ?? 'mock-key',
    baseUrl: env.BASE_URL ?? 'http://localhost:3218',
    temperature: Number(env.TEMPERATURE ?? 0.7),
    maxIterations: Number(env.MAX_ITERATIONS ?? 5),
  };
}

这里有三个细节:

二、工具层:lib/tools.mjs

工具层几乎是把第 08 章的产品原样搬进来:注册表 + 从 spec 生成 schema + error-as-data 执行器。区别只有一处,多了一个 createBaseTools(),返回预置好内置工具的注册表:

export function createBaseTools() {
  const registry = createToolRegistry();

  registry.register(
    {
      name: 'get_time',
      description: 'Get the current UTC time as an ISO 8601 string.',
      parameters: [],
    },
    () => new Date().toISOString(),
  );

  registry.register(
    {
      name: 'get_weather',
      description: 'Get the current weather for a given city.',
      parameters: [
        { name: 'location', type: 'string', description: 'City name, e.g. Shanghai.' },
      ],
    },
    (args) => {
      const location = String(args.location ?? 'Shanghai');
      const table = {
        Shanghai: '上海:晴,27°C,微风,紫外线中等',
        Beijing: '北京:多云,24°C,西北风 3 级',
        London: '伦敦:小雨,15°C,体感偏凉',
      };
      return table[location] ?? `${location}:晴,20°C`;
    },
  );

  registry.register({ /* ... add ... */ }, (args) => { /* ... */ });

  return registry;
}

选这三个内置工具是第 08 章「内置工具集设计思路」的落地(不是随手抓的):纯函数优先get_timeget_weatheradd 都是确定性、无副作用、天然幂等的读操作,是工具集的安全地基。文件读写、Shell 执行这类功能强但带副作用的工具,要等第 18 章的权限层到了才进产品。加新工具 = 调一次 register,注册表、循环、mock 都不需要改。这是 ch08 兑现的承诺。

三、主循环:lib/loop.mjs

主循环是第 07 章那个 50 行 while产品版。骨架完全一样(think → act → observe、两个停止条件),两处升级是「组装」出来的:

export async function runAgent(initialMessages, { callLlm, tools, maxIterations = 5, onEvent }) {
  const messages = [...initialMessages];
  let iterations = 0;

  while (iterations < maxIterations) {
    // think:完整历史 + 工具清单 → 模型决策
    const toolList = modelTools(tools);
    onEvent?.({ type: 'think' });
    const response = await callLlm(messages, toolList);

    const assistant = { role: 'assistant', content: response.content };
    if (response.toolCalls.length > 0) assistant.tool_calls = response.toolCalls;
    messages.push(assistant);

    // 停止条件 1(正常):模型不再要工具
    if (response.toolCalls.length === 0) {
      onEvent?.({ type: 'done', finalText: response.content, iterations: iterations + 1 });
      return { messages, finalText: response.content, iterations: iterations + 1 };
    }

    // act + observe:逐个执行工具,结果回填(失败也是数据)
    for (const call of response.toolCalls) {
      onEvent?.({ type: 'act', call });
      const outcome = executeTool(tools, call.name, call.arguments);
      const content = outcome.ok ? stringifyToolResult(outcome.result) : outcome.error;
      messages.push({ role: 'tool', tool_call_id: call.id, content });
      onEvent?.({ type: 'observe', call, content });
    }

    iterations += 1;
  }

  // 停止条件 2(保险丝):maxIterations 到达,用最后一段文本兜底
  const lastAssistant = [...messages].reverse().find((m) => m.role === 'assistant');
  const finalText = lastAssistant?.content ?? '';
  onEvent?.({ type: 'done', finalText, iterations, truncated: true });
  return { messages, finalText, iterations };
}

两处升级,处处指向「产品」:

  1. tools 从裸数组升级成注册表modelTools(tools)registry.list() 生成模型面对的工具清单(每个元素是 { name, description, schema },schema 就是注册时从 spec 生成的那份);执行走 executeTool,于是 ch07 里「未知工具直接抛错」的产品版变成「错误字符串回填给模型」:工具不存在、参数不对、实现抛错,循环都不中断,模型自己读到错误再纠正。这是 ch08 的 error-as-data 接进循环的成果;
  2. 新增 onEvent trace 回调:循环在 think / act / observe / done 四个时点发出事件,但不知道谁在听。界面层(CLI)把它打印成日志;将来可以换成事件流、换成本地存储。这是「日志属于界面」在代码里的落点:core 保持沉默,界面负责表达。

callLlm 就是 ch04 的 Provider 抽象装进产品后的形状:chat(messages, tools) 接收内部消息与模型工具清单,自己翻译成厂商 wire 格式、发 HTTP、解析回 { content, toolCalls, stopReason, usage }。循环对它一无所知。这正是阶段一所有零件各自独立、互不越位的总装效果。

四、界面层:lib/cli.mjs

界面层是组合根:把上面三个 core 模块拧成产品。它只做三件事:读输入、调 core、打印输出。

const config = loadConfig();                 // 1. 读配置(环境变量 + 默认值)
const chat = createChatProvider(config);     // 2. 建 Provider(callLlm)
const registry = createBaseTools();          // 3. 建工具注册表

// 用户输入 → runAgent → 打印
const messages = [
  { role: 'system', content: SYSTEM_PROMPT },
  { role: 'user', content: question },
];
const result = await runAgent(messages, {
  callLlm: chat,
  tools: registry,
  maxIterations: config.maxIterations,
  onEvent: makeTracePrinter(),               // 日志:界面自己的事
});
console.log(`\n${result.finalText}\n`);

交互形态两种:带参数启动(把问题作为启动参数传入)一次性问答后退出;不带参数进入 REPL:you> 提示符循环读行,/tools 列出当前工具,/exit 退出。读 stdin 用 Node 的 readline/promisesfor await (const line of rl) 让管道输入(EOF)也能自然结束,不会把正在进行的问答掐断。

一次完整对话在界面层看到的时序是这样的:

sequenceDiagram
  participant U as 用户
  participant CLI as lib/cli.mjs 界面
  participant LOOP as lib/loop.mjs 主循环
  participant PROV as lib/provider.mjs 传输
  participant LLM as mock LLM
  participant TOOL as 工具注册表

  U->>CLI: 提问(stdin)
  CLI->>LOOP: runAgent(messages, { callLlm, tools })
  loop while 模型还要工具
    LOOP->>PROV: callLlm(messages, 工具清单)
    PROV->>LLM: POST /v1/chat/completions
    LLM-->>PROV: tool_calls(可能多个)
    PROV-->>LOOP: { content, toolCalls }
    LOOP->>TOOL: executeTool(name, args)
    TOOL-->>LOOP: { ok, result } 或 { ok, error }
    LOOP->>LOOP: tool 消息回填 messages
  end
  LOOP-->>CLI: { messages, finalText, iterations }
  CLI-->>U: 打印 trace + 最终答案(stdout)

注意时序图里所有决策都发生在 core,界面只在两头出现:收用户输入、打最终输出。中间那几轮循环对 CLI 完全透明。它只通过 onEvent 被动看到 trace。

组装完成:跑起来看到什么

把 mock 模型(脚本化的确定性 LLM,第一轮返回两个并行工具调用、第二轮给出最终答案)和上面四个模块装在一起,一次「帮我查一下上海的天气,顺便告诉我当前的 UTC 时间」的对话,输出长这样:

=== product v01 demo: think -> act -> observe ===

user: 帮我查一下上海的天气,顺便告诉我当前的 UTC 时间。

--- round 1 (think): the model decides ---
  model sees 3 tools: get_time, get_weather, add
  model returns 2 parallel tool call(s):
    get_time({})
    get_weather({"location":"Shanghai"})
--- round 1 (act): harness executes the tool ---
  get_time({})
--- round 1 (observe): tool result appended ---
  { role: 'tool', tool_call_id: 'call_time', content: "2026-08-11T12:39:34.454Z" }
--- round 1 (act): harness executes the tool ---
  get_weather({"location":"Shanghai"})
--- round 1 (observe): tool result appended ---
  { role: 'tool', tool_call_id: 'call_weather', content: "上海:晴,27°C,微风,紫外线中等" }
--- round 2 (think): the model decides ---
  model returns text: "上海今天晴,27°C,微风,适合出门,记得防晒。"
  stop_reason: stop

=== final answer ===
  上海今天晴,27°C,微风,适合出门,记得防晒。

=== result ===
  iterations: 2
  loop stopped cleanly: true

几个值得停下来的点:

常见坑与失败模式

坑一:core 反向 import 界面。 循环跑着跑着想「顺便打一行日志」,直接 import { printTrace } from './cli.mjs',分层瞬间崩塌:core 从此只能活在 CLI 里,测试要连界面一起 mock,ch15 服务化时整个 core 要拆开重写。正确姿势是 onEvent 回调:core 发事件,界面决定怎么打印。判断分层是否健康就一句话:删掉 cli.mjs,core 还能不能单独跑起来、单独被测试?

坑二:配置散落硬编码。 模型名写在 runAgent 里、地址写在 fetch 调用里、循环上限写在 while 条件里,换环境就要翻源码。所有可变参数必须收敛到 loadConfig 一处,调用方只从配置对象取。标准是:不改一行代码,光改环境变量,产品就能从 mock 切到真实供应商。

坑三:日志混进 core。runAgent 里直接 console.log 每一步,循环从此和终端绑定:浏览器里没有终端、测试里不想看日志、ch15 的 HTTP 服务里日志要进别的通道。trace 事件与打印分离(onEvent 发事件、界面打印),core 才能在任何宿主里沉默地工作。

坑四:把协议翻译塞进循环。 runAgent 里直接拼 OpenAI 的 tool_calls wire 格式,换 Anthropic 就得改循环。ch04 的结论在这里是硬约束:wire 翻译是 Provider 的职责,循环只见内部消息形状和 { content, toolCalls }

坑五:保险丝截断后没有兜底文本。 maxIterations 触发时直接返回空字符串,用户面对一个「什么也没说」的结束,体验像崩溃。产品版从消息历史里捞最后一轮 assistant 文本兜底,绝不让 finalText 为空(ch07 坑四的延续)。

坑六:REPL 退出处理草率。 readline/question 挂在 while 循环里,管道输入 EOF 时 promise 永远不 settle,进程不退出或提前被杀。用 for await (const line of rl) 迭代行,EOF 自然结束、/exit 显式 break。退出路径和输入路径一样是一等公民。

小结

下一章(ch15)我们进入服务化 v02:事件协议设计。同一个 core,从「CLI 直连」换成「HTTP/SSE 服务」,把 onEvent 这个 trace hook 演进成正式的流式事件协议(text_delta / tool_call_start / tool_result / approval_request / done / error),并支持多会话。届时你会看到:因为本章把 core 和界面分得干净,服务化只是换一个界面,core 一行不改。先把本章练习做完,亲手把产品 core 的三个 stage(配置 → 工具 → 主循环)组装出来。

延伸阅读

完成阅读,去做练习 →