写在前面:系列是为了帮助大家更好的去理解Agent Harness基础设施,并不是想重复造轮子,真实开发建议选择一个成熟的SDK或Harness框架,才是最合适的选择~
1. loop 里缺一个方向盘
之前写的《自己动手实现一个 agent:生命周期钩子》给 harness 装上了强制底线。到目前,你的 harness 会派活(子代理)、会积累(技能和规则)、有强制底线(钩子能拦该拦的),最后缺的,是人的指挥入口。
loop 转起来之后,你怎么指挥它?现在的答案只有一个:发消息。想换模型,只能重启改环境变量;想清掉刚才的对话,只能重新开进程;想看看它现在能做什么,没有入口。这些动作不是「跟模型对话」,是「指挥 harness 本身」。
cmd 是人在 loop 内的干预入口。loop 不是封闭的黑盒。人除了喂消息给模型,还要能指挥 harness 本身——把「常问的」「常改的」「常控的」做成命令:
- 常问的:我现在能用什么(/help)、现在用的哪个模型(/model)
- 常改的:换个模型(/model)、重置记忆(/clear)
- 常控的:停(/exit)
四个命令,撑起一个可用的命令层。这就是本篇要写的:给 harness 装一个方向盘。
2. 先看 Claude Code 怎么做(点到为止)
命令以 / 开头,在发给模型之前被本地拦下处理,不进模型上下文。slash 命令在 REPL 里以 / 开头输入,官方把它定位成「在会话内控制 CLI」的快捷方式——它拦截在本地,不是发给模型的对话内容。我们自建 harness 的 / 前缀分界线,出处就在这。
官方命令按用途分几类,点到为止:
| 用途 | 命令(举例) | 干的事 |
|---|---|---|
| 帮助 | /help | 列出所有可用命令、快捷键与提示 |
| 模型 | /model /effort | 换模型、调推理力度 |
| 上下文 | /context /compact /clear /rewind | 看占用、压缩、清空、回退 |
| 运行 | /loop /goal /batch | 定时循环、可测终态、批量并行 |
| 配置 | /config /permissions /hooks /memory | 配置面板、权限、钩子、记忆 |
这一长串,对应的是产品在真实场景里的各种需求——并行工作、定时任务、云端同步。自建 harness 用不上那么多。命令系统这一层要搭的,是「命令怎么注册、怎么路由」;至于命令本身,够用即止。
所以我只做四个,对应人操作 harness 的最高频四件事:看帮助、换模型、清记忆、退出。贵精不贵多——命令表是「人在 loop 内的干预入口」,每多一个命令就多一份入口面,够用即止。
3. Demo 先行:命令注册表 + 路由分发
代码来自配套工程examples/first-agent/step8-cmd/index.js,零依赖、无 API key。先给代码,再跑通看真实输出。
3.1 命令注册表
命令系统的第一块是注册表——登记「有哪些命令、怎么调」。核心就一句话:注册即得命令。
// ============ 命令注册表 ============// 核心就一句话:注册即得命令。要加新命令,register 一个// { name, description, handler } 进去,路由自动认它。// 真实工程:命令表还缺别名与分类。真实工程应支持命令别名(/quit = /exit)、// 分类分组(对话 / 文件 / 上下文 / 运行),帮助信息按分类展示// (Claude Code 的命令全录就是带分组的)。classCommandRegistry{constructor(){this.commands=newMap()}register(cmd){this.commands.set(cmd.name,cmd);returncmd}get(name){returnthis.commands.get(name)}list(){return[...this.commands.values()]}}一个命令就三个字段:name(叫什么)、description(说明,/help 靠它展示)、handler(干活的函数)。注册进 Map,路由就能认它。
3.2 内置命令:贵精不贵多,四个
handler(ctx, args)返回要展示的文本,由路由统一加[cmd]前缀打印。ctx是会话上下文,命令通过它读/改 harness 内部状态。
// ============ 内置命令(贵精不贵多,4 个)============// handler(ctx, args) -> string:返回要展示的文本,由路由统一加 [cmd] 前缀打印。// ctx 是 Session(会话上下文),命令通过它读/改 harness 内部状态。// /help —— 第一个该做的命令:它把剩下所有命令教给用户。// 新手不用背命令表,/help 就是命令表在运行时的投影。constcmdHelp=(ctx)=>{constlist=ctx.commands.list()return'可用命令('+list.length+'):\n'+list.map((c)=>`${c.name.padEnd(7)}${c.description}`).join('\n')}// /model —— 显示当前模型,可切换(mock/real)。// 它是 harness 级操作:直接改 loop 用的 provider。constcmdModel=(ctx,args)=>{consttarget=args[0]if(!target)return`当前模型:${ctx.model.describe()}`if(target==='mock'||target==='real'){if(ctx.model.set(target))return`已切换模型 →${target}`return`无法切换${target}:未设置 OPENAI_API_KEY,维持${ctx.model.name}`}return`未知模型${target}。可用:mock | real`}// /clear —— 清空当前会话日志。// 直接操作 SessionLog:换一个新日志,loop 同步指向它。等于「重置记忆」。// 这证明 cmd 是 harness 级操作——普通对话做不到让 agent 失忆。constcmdClear=(ctx)=>{constoldCount=ctx.clearLog()return`会话日志已清空:原${oldCount}条事件 → 新建空日志`}// /exit —— 退出程序。// 不直接调 process.exit:把 running 置 false,由主循环停。// 好处是资源能正常收尾(日志落盘、连接关闭之类),而且可测试。constcmdExit=(ctx)=>{ctx.running=falsereturn'退出。再见!'}// 真实工程:内置命令写死在代码里。真实工程应支持自定义命令从配置文件挂载//(Claude Code 的自定义 slash command 就是从配置文件 / skill 注册进来的)——命令// 注册表天然支持外部 register,把「读配置 → 逐个 register」接进 Session 即可。constBUILTIN_COMMANDS=[{name:'/help',description:'列出所有可用命令与说明',handler:cmdHelp},{name:'/model',description:'显示当前模型,可切换(mock/real)',handler:cmdModel},{name:'/clear',description:'清空当前会话日志',handler:cmdClear},{name:'/exit',description:'退出程序',handler:cmdExit},]3.3 路由分发:/ 开头是命令,不是对话
注册表有了,剩下是路由——决定一条输入走命令还是走 loop。
// ============ 路由分发:cmd 与 agent loop 的分界线 ============// / 开头 → 命令表命中 → 执行 handler(不进 loop)// / 开头 → 命令表 miss → 提示 + 列出可用命令// 其它 → 进 loopasyncfunctionroute(input,ctx){consttext=input.trim()if(!text)return{kind:'empty'}console.log(`\n[user]${text}`)// 所有输入统一由路由打印 [user]if(text.startsWith('/')){// 真实工程:这里用空白切分取词,/model real 只取 args[0] 够用,但复杂参数会错//(带引号的值、多段子参数、可选标志)。真实工程应做完整参数解析:// /model <name> [args] 的词法拆分 + 参数个数 / 取值校验 + 未知参数报错提示。const[name,...args]=text.split(/\s+/)constcmd=ctx.commands.get(name)if(!cmd){console.log(`[cmd] 未知命令${name}。可用命令:${ctx.commands.list().map((c)=>c.name).join(' ')},输入 /help 查看说明。`)return{kind:'unknown',name}}constreply=awaitcmd.handler(ctx,args)if(reply)console.log('[cmd] '+String(reply).split('\n').join('\n[cmd] '))return{kind:'cmd',name}}// 不以 / 开头 → 这不是命令,是给模型的对话 → 进 step4 的 turn 循环awaitctx.loop.turn(text)return{kind:'turn'}}3.4 跑通它
配套工程克隆下来,直接跑。下面输出逐字取自本机真实运行记录(step8-cmd/PRACTICE.md,Windows 11 / Node v22.12.0 / mock 模型,我落稿前又复跑核对过一遍):
$ node step8-cmd/index.js ===== step8-cmd:cmd 与内置命令 ===== 预置输入序列模拟一次交互式会话(便于取证);/ 开头进命令路由,否则进 agent loop。 [user] /help [cmd] 可用命令(4): [cmd] /help 列出所有可用命令与说明 [cmd] /model 显示当前模型,可切换(mock/real) [cmd] /clear 清空当前会话日志 [cmd] /exit 退出程序 [user] /nope [cmd] 未知命令 /nope。可用命令:/help /model /clear /exit,输入 /help 查看说明。 [user] 你好,我是来学 agent 的 [assistant] (mock)收到:你好,我是来学 agent 的 [user] /model [cmd] 当前模型:mock(确定性假模型,无 key 可跑) [user] /model real [cmd] 无法切换 real:未设置 OPENAI_API_KEY,维持 mock [user] /model mock [cmd] 已切换模型 → mock [user] /clear [cmd] 会话日志已清空:原 3 条事件 → 新建空日志 [user] /exit [cmd] 退出。再见! —— demo 结束 ——一条一条对。/help把四个命令列全——命令表在运行时的投影。/nope是未知命令:提示 + 列出可用命令,不崩。你好,我是来学 agent 的没以 / 开头,进了 loop,回[assistant]——命令和对话在展示上分得清清楚楚。/model显示当前模型;/model real没 key,被优雅拒绝,不抛异常、不碰网络;/model mock走成功路径。/clear清空日志;/exit退出。
这里有个我在 Windows 上踩过的真实细节,值得单独说。demo 要区分「直接运行」还是「被测试 import」,我最初用process.argv[1] === import.meta.url判断。Windows 上这俩永远不相等——argv[1]是相对路径(step8-cmd/index.js),import.meta.url是file:///D:/...绝对 URL。改成resolve(process.argv[1]) === fileURLToPath(import.meta.url)才正确:resolve按平台分隔符归一化成绝对路径,fileURLToPath把 file:// URL 转成 Windows 盘符路径。这一坑和我在《动手开发你的第一个 agent :让它有记忆》里踩过的盘符重复坑同源——Windows 上 ESM 路径一律认fileURLToPath。
4. 两个核心设计
代码跑通了,两个设计值得单独拆开讲。
4.1 命令注册表:注册即得命令
命令系统的第一个设计是注册表。核心就一句话:注册即得命令。要加新命令,register 一个{ name, description, handler }进去,路由自动认它,/help 自动列出它——不需要改路由,不需要改主循环。
为什么三个字段就够?name让路由能查,description让用户能看(/help 的每一行说明都来自它),handler让命令能干活。handler(ctx, args)的签名里,ctx是关键——命令不是孤立的字符串处理,它拿到的是整个会话上下文:命令注册表、模型、工具、日志、loop。所以命令能读/改 harness 内部状态,这是「干预入口」的物理前提。
这里单独立一条:/help 是第一个该做的命令。它不干任何实事,但它教会用户剩下的所有命令——新手不用背命令表,/help 就是命令表在运行时的投影。有了它,你加新命令不用写文档,/help 自动展示。没有它,命令再多用户也不知道有。
4.2 路由分发:/ 开头即命令,不进 loop
第二个设计是路由,它是 cmd 与 agent loop 的分界线。规则就两条:
- 以 / 开头 → 查命令表,命中执行 handler,不进 loop
- 不以 / 开头 → 进《动手开发你的第一个 agent:最小的 agent loop》里那个
ReactLoop.turn(),正常对话
为什么 / 开头就不进 loop?因为「换模型」「清记忆」这类话,如果被当成用户消息喂给模型,模型会当作聊天话题来「回答」,而不是当作 harness 控制指令来「执行」。/前缀是路由的分界线,把「控制 harness」和「跟模型对话」两类输入从语法上分开。这是我在第 2 节核过的 Claude Code 行为——slash 命令被本地拦截,模型看不到。
复用的 loop 从哪来?就是第一步那个 turn 循环,它不关心输入怎么来,只关心「boundary → 写用户消息 → step 请求模型 → 直到出文本」。唯一改动:turn()里不再自己打印[user],统一由路由打印——命令和普通输入在展示上完全一致。
测试能证明「命令不进 loop」。工程里step8-cmd/test.js用了一个「间谍 loop」:替换真实 loop,只计数turn被调用几次。
// ① 以 / 开头的输入被路由到命令、不进 loop// 用一个「间谍 loop」替换真实 loop:只计数 turn 被调用了几次。// 如果命令输入也触发了 turn,计数就会 +1,测试就失败。asyncfunctiontestCommandRoutedNotToLoop(){consts=newSession()letturnCalls=0s.loop={turn:async()=>{turnCalls++},// 间谍 loop:不真跑模型,只计数}awaitcaptureLog(()=>route('/help',s))assert.equal(turnCalls,0,'/help 是命令,不应进 loop')awaitcaptureLog(()=>route('你好,这是普通对话',s))assert.equal(turnCalls,1,'不以 / 开头的输入应进 loop')awaitcaptureLog(()=>route('/clear',s))assert.equal(turnCalls,1,'/clear 是命令,不应进 loop')console.log('✓ ① 以 / 开头的输入被路由到命令、不进 loop')}/help、/clear触发 0 次 turn,普通输入触发 1 次——命令确实没进 loop。真跑一遍,五条全绿(输出逐字取自 PRACTICE):
$ node step8-cmd/test.js ✓ ① 以 / 开头的输入被路由到命令、不进 loop ✓ ② /help 列出所有已注册命令 ✓ ③ 未知命令提示并列出可用命令 ✓ ④ /clear 清空会话日志(原 6 条事件 → 0 条) ✓ ⑤ /model 显示当前模型(mock) 全部通过 ✅(node step8-cmd/test.js 零依赖跑通)把分界线画出来,就是这张图:
/clear和/model是 harness 级操作的例子,值得单独说。
/clear直接操作会话日志:换一个全新的空日志,loop 同步指向它。clearLog()就几行:
// 清日志:换新 SessionLog,并让 loop 同步指向它(否则 loop 还在用旧日志)// 真实工程:这里直接换新日志,旧日志等同丢弃。真实工程清空策略应可配置:// 归档旧日志文件(加时间戳留存)或确认后才清,避免误清重要历史。clearLog(){constoldCount=this.log.events.lengththis.log=newSessionLog()this.loop.log=this.logreturnoldCount}两个引用必须同步换。只换session.log不改loop.log,loop 继续往旧日志写,清空形同虚设。这证明 cmd 是 harness 级操作——普通对话做不到让 agent 失忆,命令可以。/model同理:它直接改 loop 用的 provider,把「启动时读一次环境变量」升级成「运行期可变状态」——换模型不用重启。
/exit为什么不是process.exit?直接调process.exit会立刻终止进程,日志来不及落盘、连接来不及关,也没法测试。改成把ctx.running置 false,主循环if (!ctx.running) break停——资源能正常收尾,而且route()返回后测试还能断言。demo 末尾能打出「—— demo 结束 ——」,就是这条路走通了。
命令机制这五处,demo 都做了最省事的简化,我逐个交代怎么省事、真实怎么补。配置持久化:/model 切换是内存态、重启即还原,真实工程落盘写配置、启动时读取恢复,密钥走配置/环境变量统一管理。参数解析:demo 空白切分取 args[0],带引号的值、多段子参数会错,真实工程做词法拆分、参数个数/取值校验、未知参数报错。自定义命令:demo 内置写死,真实工程从配置文件挂载(Claude Code 的自定义 slash command 就从配置/skill 注册),注册表支持外部 register。清空策略:/clear 直接换新日志丢弃,真实工程可配置归档旧日志加时间戳留存或确认后才清。命令组织:demo 命令表无分类无别名,真实工程命令带别名(/quit=/exit)、按对话/文件/上下文/运行分组,帮助按分类展示(Claude Code 命令全录就带分组)。
5. 对照 dsh:命令层是 harness 的门面
写到这里,命令系统的两层都搭完了。对照 dsh,我想把「命令层是 harness 的门面」这句话讲透。
dsh 的「门面」长在启动期。我在《给 DeepSeek Harness 加一个自定义工具》里拆过两层:boot profile(--profile web,只有web/headless,决定「启动哪一棵插件树」)和agent-preset(standard/code/minimal/cordis四份 YAML,是「人格 + 工具组合」,挂进 boot profile 的插件树)。这两层都是启动时定下来的——选好壳、选好人格,跑起来就不能改,换 preset 得重启。
我们的命令层是「运行期」的门面。loop 转起来之后,你靠 / 命令指挥它:/model 换模型,/clear 清记忆,/exit 停。一个是启动前选,一个是运行中改。对照看:
这个对应关系,落到最小实现上就是:/model 是「换 preset 的人格」的运行期最小形态。dsh 换 preset 要重启(启动期配置),我们的 /model 一条命令运行中切(运行期命令)。能力上差着数量级——dsh 的 preset 决定一整套人格和工具组合,我们只切 provider——但「门面」这件事是同一件:都是「人怎么跟 harness 打交道」的界面。
诚实边界交代一句:dsh 部分我只引用自己拆解文里的结论(boot profile 只有 web/headless、preset 是人格+工具组合),本地没有 dsh 源码,不展开任何源码细节。
顺带补一刀。我在《DeepSeek Harness 架构拆解》那篇点过它一个短板:缺一个驻留终端的持续对话入口。headless 是一次性任务,web 是浏览器 UI,没有一个「你坐进去、敲命令、它回话」的终端界面。这一条回头看,正好反衬命令层的分量——面向开发者的 harness,上手手感很大程度取决于指挥它的入口长什么样。dsh 缺的那格,你刚用几十行代码补上了。
6. 结论:给 harness 装方向盘
cmd 就是那个方向盘。方向盘贵精不贵多,四个旋钮够用:/help 看路,/model 换挡,/clear 回空挡,/exit 熄火。命令层不是 harness 的功能,是 harness 的门面——用户不看源码,看的就是命令。把「常问的、常改的、常控的」做成命令,你就不必在启动前想清楚一切,loop 跑起来随时能指挥。
记住:内置命令贵精不贵多,但 /help 一定要第一个做。它教会用户剩下的所有命令。
把这个系列的产出连起来看,你的 harness 已经不再是「一个循环」——它能派活、能积累、有底线、能被指挥。还差的部分(更完整的插件系统、多 Agent 编排、持久化加固),后面空了再继续写~