尧图精选

自己动手写Agent Harness【cmd-builtin】:用TaoToken统一Key打通cmd与内置命令注册表

🕒 发布时间:2026/10/27 19:05:02 📁 来源:尧图网络
1. 从「只会发消息」到「能指挥自己」cmd-builtin 要解决的真问题如果你已经写过一个最小 agent loop大概率会遇到同一个尴尬loop 转起来了但你跟它的全部交互方式只剩「发一条消息」。想换模型改环境变量重启。想清掉刚才那堆跑偏的对话关掉进程重开。想知道它现在到底支持哪些能力没有入口只能翻源码。这不是模型能力的问题是 harness 缺了一层「指挥入口」。Agent Harness 这个词听起来大拆开看其实就四件事派活子代理/工具调用、积累技能与规则、底线生命周期钩子、指挥命令层。前三件很多教程都讲过第四件——也就是本篇要写的 cmd-builtin 模块——经常被跳过但它恰恰决定了你这个 CLI Agent 好不好用。cmd-builtin 的核心就两个东西命令注册表和路由分发。注册表回答「有哪些命令、怎么调」路由回答「一条输入到底该走命令还是走模型」。把这两块搭好你就能用/help看能力、/model换模型、/clear重置记忆、/exit优雅退出全程不用重启进程。这篇面向的是本地 CLI Agent 开发场景假设你已经有一个能跑的 turn 循环没有也没关系路由那部分逻辑是独立的。我会给出可复制的注册表骨架、路由分发代码、验证脚本最后演示怎么通过 TaoToken 的统一 Key 把模型调用接进来让 cmd 和内置命令跑通完整链路。全程零依赖起步能跟做。2. 前置准备TaoToken 统一 Key 与项目骨架在写命令层之前先把「模型从哪来」这件事定下来。自建 harness 最烦的一点是mock 模型跑得挺爽一接真实模型就要处理各家 SDK、各家 Key、各家 base_url。我的做法是统一走一个兼容 OpenAI 协议的入口这样 harness 里只需要维护一份 provider 配置。TaoToken 在这里扮演的就是这个统一通道一个 Key、一个 API 地址模型对话、编码类模型都能从同一个入口调。官网在 https://taotoken.net API 地址是 https://taotoken.net/api 接入时把它当成 OpenAI 兼容的 base_url 用即可。你需要准备的东西不多Node.js 18我用的是 v22ESM 原生支持一个 TaoToken 的 API Key在控制台创建https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentcmd_builtin_console一个空目录比如first-agent/step8-cmd/目录结构建议这样命令层和模型层分开后面加命令不用动模型代码step8-cmd/ index.js # 入口预置输入序列 主循环 session.js # 会话上下文注册表、模型、日志、loop 的聚合 registry.js # 命令注册表 commands.js # 内置命令实现 router.js # 路由分发 provider.js # 模型 providermock / real 双实现 test.js # 验证脚本环境变量只留一个别搞一堆# Linux / macOS export TAOTOKEN_API_KEY你的key # Windows PowerShell $env:TAOTOKEN_API_KEY你的key注意Key 只从环境变量读不要写进代码、不要提交到仓库。后面/model real能不能切换成功判断依据就是「这个变量在不在」。3. 命令注册表注册即得命令命令系统的第一块是注册表。它的设计目标只有一句话注册即得命令。你往注册表里 register 一个{ name, description, handler }路由就自动认它/help就自动列出它——不需要改路由不需要改主循环。为什么三个字段就够name让路由能查description让用户能看/help的每一行说明都来自它handler让命令能干活。handler(ctx, args)的签名里ctx是关键——命令拿到的不是几个字符串而是整个会话上下文注册表、模型、日志、loop 全在里面。所以命令能读能改 harness 内部状态这才是「干预入口」的物理前提。// registry.js export class CommandRegistry { constructor() { this.commands new Map() } register(cmd) { if (!cmd.name || !cmd.handler) { throw new Error(命令必须有 name 和 handler) } this.commands.set(cmd.name, cmd) return cmd } get(name) { return this.commands.get(name) } list() { return [...this.commands.values()] } }真实工程里命令表还缺两样东西别名和分类。别名解决/quit等于/exit这种习惯问题分类解决/help输出太长的问题——按「对话 / 文件 / 上下文 / 运行」分组展示。这两样都可以在注册表上加不影响主流程我先把最小骨架跑通。内置命令我建议只做四个对应人操作 harness 的最高频四件事看帮助、换模型、清记忆、退出。命令表是「人在 loop 内的干预入口」每多一个命令就多一份入口面够用即止。// commands.js export const cmdHelp (ctx) { const list ctx.commands.list() return 可用命令 list.length \n list.map((c) ${c.name.padEnd(7)}${c.description}).join(\n) } export const cmdModel (ctx, args) { const target args[0] if (!target) return 当前模型${ctx.model.describe()} if (target mock || target real) { if (ctx.model.set(target)) return 已切换模型 → ${target} return 无法切换 ${target}未设置 TAOTOKEN_API_KEY维持 ${ctx.model.name} } return 未知模型 ${target}。可用mock | real } export const cmdClear (ctx) { const oldCount ctx.clearLog() return 会话日志已清空原 ${oldCount} 条事件 → 新建空日志 } export const cmdExit (ctx) { ctx.running false return 退出。再见 } export const BUILTIN_COMMANDS [ { name: /help, description: 列出所有可用命令与说明, handler: cmdHelp }, { name: /model, description: 显示当前模型可切换mock/real, handler: cmdModel }, { name: /clear, description: 清空当前会话日志, handler: cmdClear }, { name: /exit, description: 退出程序, handler: cmdExit }, ]/help是第一个该做的命令。它不干任何实事但它教会用户剩下的所有命令——新手不用背命令表/help就是命令表在运行时的投影。有了它你加新命令不用写文档/help自动展示没有它命令再多用户也不知道有。/exit有个细节值得单独说不要直接调process.exit。直接退出会立刻终止进程日志来不及落盘、连接来不及关而且没法测试。改成把ctx.running置 false由主循环if (!ctx.running) break停——资源能正常收尾route()返回后测试还能断言。4. 路由分发/开头是命令不是对话注册表有了剩下是路由——决定一条输入走命令还是走 loop。规则就两条以/开头 → 查命令表命中执行 handler不进 loop不以/开头 → 进 turn 循环正常对话为什么/开头就不进 loop因为「换模型」「清记忆」这类话如果被当成用户消息喂给模型模型会当作聊天话题来「回答」而不是当作 harness 控制指令来「执行」。/前缀是路由的分界线把「控制 harness」和「跟模型对话」两类输入从语法上分开。// router.js export async function route(input, ctx) { const text input.trim() if (!text) return { kind: empty } console.log(\n[user] ${text}) if (text.startsWith(/)) { const [name, ...args] text.split(/\s/) const cmd ctx.commands.get(name) if (!cmd) { const names ctx.commands.list().map((c) c.name).join( ) console.log([cmd] 未知命令 ${name}。可用命令${names}输入 /help 查看说明。) return { kind: unknown, name } } const reply await cmd.handler(ctx, args) if (reply) { console.log([cmd] String(reply).split(\n).join(\n[cmd] )) } return { kind: cmd, name } } await ctx.loop.turn(text) return { kind: turn } }这里有个真实工程要补的坑demo 用空白切分取args[0]/model real够用但带引号的值、多段子参数、可选标志就会错。真实工程应该做完整的词法拆分 参数个数/取值校验 未知参数报错提示。我先把最小可用版跑通参数解析留作扩展点。会话上下文Session把注册表、模型、日志、loop 聚合在一起命令通过它读改状态// session.js import { CommandRegistry } from ./registry.js import { BUILTIN_COMMANDS } from ./commands.js export class SessionLog { constructor() { this.events [] } push(e) { this.events.push(e) } } export class Session { constructor() { this.commands new CommandRegistry() BUILTIN_COMMANDS.forEach((c) this.commands.register(c)) this.log new SessionLog() this.running true this.model createModel(mock) this.loop { log: this.log, turn: async (t) { /* 见第 5 节 */ } } } clearLog() { const oldCount this.log.events.length this.log new SessionLog() this.loop.log this.log // 两个引用必须同步换 return oldCount } }clearLog()里那句「两个引用必须同步换」是踩过的坑只换session.log不改loop.logloop 继续往旧日志写清空形同虚设。/model同理它直接改 loop 用的 provider把「启动时读一次环境变量」升级成「运行期可变状态」——换模型不用重启。5. 接入 TaoToken让/model real真的能切前面/model里判断的是TAOTOKEN_API_KEY在不在。现在把 provider 写出来让 mock 和 real 两条路都通。// provider.js const BASE_URL https://taotoken.net/api export function createModel(kind) { let current kind return { name: current, describe() { return current mock ? mock确定性假模型无 key 可跑 : real${BASE_URL} }, set(target) { if (target real !process.env.TAOTOKEN_API_KEY) return false current target return true }, async chat(messages) { if (current mock) { const last messages[messages.length - 1] return mock收到${last.content} } const res await fetch(${BASE_URL}/v1/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${process.env.TAOTOKEN_API_KEY}, }, body: JSON.stringify({ model: gpt-4o-mini, messages, temperature: 0.7, }), }) if (!res.ok) { throw new Error(模型请求失败${res.status} ${await res.text()}) } const data await res.json() return data.choices[0].message.content }, } }loop 的turn就三件事写用户消息进日志、请求模型、把回复写回日志并打印。// 在 Session 构造里替换 loop.turn this.loop { log: this.log, turn: async (text) { this.log.push({ role: user, content: text }) const reply await this.model.chat(this.log.events) this.log.push({ role: assistant, content: reply }) console.log([assistant] reply) }, }入口文件把预置输入序列跑一遍方便取证// index.js import { Session } from ./session.js import { route } from ./router.js const s new Session() const inputs [ /help, /nope, 你好我是来学 agent 的, /model, /model real, /model mock, /clear, /exit, ] console.log( step8-cmdcmd 与内置命令 ) for (const input of inputs) { if (!s.running) break await route(input, s) } console.log(\n—— demo 结束 ——)跑起来node step8-cmd/index.jsmock 模式下你会看到/help列出四个命令、/nope提示未知命令、普通输入进 loop 回[assistant]、/model real在没设 Key 时被优雅拒绝、/clear清空日志、/exit退出。设了TAOTOKEN_API_KEY之后/model real切换成功再发一条普通消息就会真的走 https://taotoken.net/api 拿到模型回复——cmd 和内置命令的完整链路就通了。6. 验证与排障命令到底有没有进 loop光看输出不够得证明「命令不进 loop」。工程里用一个「间谍 loop」替换真实 loop只计数turn被调用几次。如果命令输入也触发了 turn计数就会 1测试失败。// test.js import assert from node:assert import { Session } from ./session.js import { route } from ./router.js async function captureLog(fn) { const orig console.log console.log () {} try { return await fn() } finally { console.log orig } } async function testCommandRoutedNotToLoop() { const s new Session() let turnCalls 0 s.loop { turn: async () { turnCalls } } await captureLog(() route(/help, s)) assert.equal(turnCalls, 0, /help 是命令不应进 loop) await captureLog(() route(你好这是普通对话, s)) assert.equal(turnCalls, 1, 不以 / 开头的输入应进 loop) await captureLog(() route(/clear, s)) assert.equal(turnCalls, 1, /clear 是命令不应进 loop) console.log(✓ ① 以 / 开头的输入被路由到命令、不进 loop) } await testCommandRoutedNotToLoop()node step8-cmd/test.js跑通说明分界线画对了。下面这张流程图把路由逻辑固定下来graph TD A[用户输入] -- B{trim 后空?} B --|是| C[empty 什么都不做] B --|否| D{以 / 开头?} D --|是| E{命令表命中?} E --|是| F[执行 handler 打印 cmd 结果 不进 loop] E --|否| G[提示未知命令 列出可用命令] D --|否| H[进 turn 循环 打印 assistant 正常对话]注意上面这段 mermaid 只是示意实际代码里不要引入 mermaid 依赖路由逻辑以router.js为准。排障清单按出现频率排序现象原因处理/help输出为空注册表没注册内置命令检查Session构造里是否BUILTIN_COMMANDS.forEach(register)/clear后 loop 还在写旧日志只换了session.log没换loop.logclearLog()里两个引用同步换/model real一直失败环境变量名写错或没导出确认TAOTOKEN_API_KEY在当前 shell 可见命令被当成对话发给模型路由判断用了includes(/)之类必须是startsWith(/)Windows 上入口判断永远为假process.argv[1] import.meta.url不成立改用resolve(process.argv[1]) fileURLToPath(import.meta.url)模型请求 401Key 无效或没带Bearer前缀检查 Authorization 头格式最后一条 Windows 路径坑值得展开argv[1]是相对路径step8-cmd/index.jsimport.meta.url是file:///D:/...绝对 URL两者永远不相等。正确写法是resolve(process.argv[1]) fileURLToPath(import.meta.url)——resolve按平台分隔符归一化成绝对路径fileURLToPath把 file URL 转成 Windows 盘符路径。Windows 上 ESM 路径一律认fileURLToPath。7. 下一步把命令层接到你的真实工程到这里命令注册表和路由分发两块都搭完了。回顾一下你手上有什么一个注册即得命令的注册表、四个够用的内置命令、一条/前缀分界线、一个能证明「命令不进 loop」的测试以及一条通过 TaoToken 统一 Key 接入真实模型的通道。接下来可以往三个方向扩自定义命令从配置挂载注册表天然支持外部register把「读配置 → 逐个 register」接进Session即可命令就不用写死在代码里。命令别名与分类/quit等于/exit/help按「对话 / 文件 / 上下文 / 运行」分组展示命令多了也不乱。参数解析加固把空白切分换成词法拆分支持带引号的值、可选标志、参数校验。如果你想把模型调用也统一管理起来建议直接看接入文档把 base_url 和鉴权方式固定下来https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentcmd_builtin_doc 。需要长期跑编码类任务、想让 Agent 持续在线的可以了解 Coding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentcmd_builtin_codingplan 。想先在网页里验证模型通不通、对比不同模型输出的用模型对话入口最快https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentcmd_builtin_chat 。Key 的管理和创建都在控制台https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentcmd_builtin_console 。命令层不是 harness 的功能是 harness 的门面——用户不看源码看的就是命令。把「常问的、常改的、常控的」做成命令你就不必在启动前想清楚一切loop 跑起来随时能指挥。内置命令贵精不贵多但/help一定要第一个做它教会用户剩下的所有命令。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →