产品骨架 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 快照:产品骨架。我们不发明任何新原理,只把阶段一的零件组装起来,并补上「产品」缺的三样东西:配置、日志、分层。
本章目标
读完本章并做完配套练习后,你应该能够:
- 说出「零件 demo」与「产品骨架」的差距:拼装、配置、日志、分层缺一不可,以及每一样解决什么问题;
- 画出一个产品 Agent 的分层架构:core(配置 / 传输 / 工具 / 主循环)与界面(CLI)的分界在哪里,依赖方向为什么是单向的;
- 看懂
config.mjs(环境变量 + 默认值)、tools.mjs(ch08 注册表原样复用 + 内置工具集)、loop.mjs(ch07 循环升级:注册表执行 + trace 回调)、cli.mjs(组合根:读输入 → 调 core → 打印输出)各自的职责; - 解释为什么 core 的函数全部采用「纯函数或依赖注入」,以及这给测试和后续演进带来什么;
- 说出本章产品的两个失败模式:core 反向依赖界面、配置散落硬编码。
概念与动机:为什么「零件」到「产品」需要骨架
先把「不装骨架」的后果演一遍。假设我们把第 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);
}
这个版本能跑,但你已经能闻到产品化的要求从四面八方冒出来:
- 拼装:真实对话要走完整循环(think → act → observe)、要工具、要 Provider、要停止条件。把它们手拉手接起来,是第一个要解决的事,而且接法必须是可持续的,因为 ch15 还要往这个接缝里塞东西。
- 配置:模型名、API key、地址、温度、循环上限……demo 里全是硬编码。换一个供应商就要改源码。产品要求「运行参数可变、代码不变」:环境变量 + 默认值是最朴素也最标准的答案(12-Factor App 的配置规范)。
- 日志:用户输入了一句话,Agent 内部想了什么、调了哪个工具、工具返回了什么、最后为什么停下来,这些 trace 不打印出来,用户面对的就是一个黑盒(或者更糟:看起来像卡死)。日志要成为产品的一等公民,但日志属于界面,不该污染核心逻辑。
- 分层:这是最本质的一条。上面三点如果全写在一个文件里,功能也能用,但下一次改动(加流式、加权限、加存储)就要在屎山上再叠一层。产品必须回答「这段代码属于哪个层」,并且让依赖方向单向:界面依赖 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["环境变量"]
几个关键点:
- core 的四个模块各管一件事:配置(读参数)、传输(翻译 wire 格式)、工具(注册与执行)、主循环(决策循环)。它们之间除了
loop依赖tools的executeTool(core 内部依赖,允许),互不认识; - 界面是唯一的组合根:
cli.mjs把四样东西组装起来:读配置、建 Provider、建注册表,然后把callLlm和tools注入runAgent; - 依赖方向单向:core 不 import 界面。代价是界面要多写几行组装代码,收益是 core 可以脱离 CLI 被测试、被复用,ch15 把它包进 HTTP 服务时,core 一行不用改;
- core 与协议解耦:
runAgent拿到的是callLlm(messages, tools),它不知道背后是 OpenAI 格式还是 Anthropic 格式(ch04 已论证);tools参数是注册表,循环只面向list()/get()(ch08 已论证)。
一、配置层: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),
};
}
这里有三个细节:
- 缺省即本地 mock:
BASE_URL默认指向脚本化 mock 服务器、API_KEY默认mock-key,新用户 clone 下来什么都不配就能跑,配了环境变量就切换真实供应商。这就是「配置」对开发体验的意义; - 数值字段做
Number()解析:环境变量永远是字符串,TEMPERATURE='0'如果直接??会被当成真值,必须显式转换; - 纯函数:只读传入的 env 对象,不碰全局状态:浏览器里没有
process.env,练习里传空对象也能测。
二、工具层: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_time、get_weather、add 都是确定性、无副作用、天然幂等的读操作,是工具集的安全地基。文件读写、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 };
}
两处升级,处处指向「产品」:
tools从裸数组升级成注册表:modelTools(tools)用registry.list()生成模型面对的工具清单(每个元素是{ name, description, schema },schema 就是注册时从 spec 生成的那份);执行走executeTool,于是 ch07 里「未知工具直接抛错」的产品版变成「错误字符串回填给模型」:工具不存在、参数不对、实现抛错,循环都不中断,模型自己读到错误再纠正。这是 ch08 的 error-as-data 接进循环的成果;- 新增
onEventtrace 回调:循环在 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/promises,for 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
几个值得停下来的点:
- 并行工具调用:第一轮模型一次要了两个工具(
get_time+get_weather),循环逐个执行、各自回填一条tool消息(tool_call_id一一配对),第二轮模型看到全部结果再回答,这是 ch06 的并行能力在循环里的自然呈现; - 日志可见性:每一轮「模型在想什么、harness 在执行什么、结果是什么」都打印出来了,用户面对的不是黑盒。这套 trace 正是骨架里「日志」的成果;
- 确定性:因为 mock 是脚本化的,同样的输入永远产生同样的输出,开发、测试、演示都可复现。这也是本教程所有判题测试能跑起来的前提:用确定性脚本模型代替真实 LLM(build-your-own-agent 的 coding agent 参考实现正是同样的思路:脚本化 mock 模型 + 零依赖可运行,reference/06-coding-agent)。
常见坑与失败模式
坑一: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。退出路径和输入路径一样是一等公民。
小结
- 零件 demo 与产品之间隔着四样东西:拼装(零件接线)、配置(环境变量 + 默认值)、日志(trace 可见性)、分层(core 与界面解耦、依赖单向);
- 分层形态:core =
config(参数)+provider(传输)+tools(注册表 + 错误协议)+loop(主循环);界面 =cli(组合根,读输入 → 调 core → 打印输出 + 日志); - 主循环是 ch07 循环的产品版:
tools升级为注册表(executeTool执行、失败变数据回填模型)、新增onEventtrace 回调(core 发事件、界面打印),原理没变,工程化变了; - core 的全部函数都是纯函数或依赖注入(
callLlm/tools注入),因此可以脱离网络、脱离终端被测试,练习判题就是这么干的; - 失败模式六连:core 反向 import 界面、配置散落硬编码、日志混进 core、协议翻译塞进循环、保险丝截断无兜底、REPL 退出草率。
下一章(ch15)我们进入服务化 v02:事件协议设计。同一个 core,从「CLI 直连」换成「HTTP/SSE 服务」,把 onEvent 这个 trace hook 演进成正式的流式事件协议(text_delta / tool_call_start / tool_result / approval_request / done / error),并支持多会话。届时你会看到:因为本章把 core 和界面分得干净,服务化只是换一个界面,core 一行不改。先把本章练习做完,亲手把产品 core 的三个 stage(配置 → 工具 → 主循环)组装出来。
延伸阅读
- shareAI-lab/learn-claude-code · s01: Agent Loop:本章「harness 层」概念的直接参照:模型与真实世界之间的第一道连接,一个循环 + 工具执行器即 Agent 最小内核;内含对 Claude Code 主循环(1729 行
query.ts)的剖析:复杂字段全是保护机制,内核就是 30 行while。 - TheSeydiCharyyev/build-your-own-agent · reference/06-coding-agent:Claude-Code 风格 CLI agent 的零依赖参考实现:loop + 文件/Shell 工具 + 可插拔模型,脚本化 mock 模型保证无需 API key 可复现;本章 demo 与判题测试的「确定性 mock」思路与之同源。
- 12-Factor App · III. Config:环境变量 + 默认值做配置的经典规范(配置与代码严格分离、环境可变)。
- pguso/agents-from-scratch · Lesson 06: The Agent Loop:agent = 循环 + 状态,
max_steps终止条件(第 07 章已引,这里作为主循环产品化的底座再看一遍)。