Utah:灵感来自OpenClaw的开源缰绳Harness智能体


事件驱动架构比传统框架更靠谱:详细拆解了Utah项目的六个核心函数、子智能体委托机制、上下文管理技巧,以及如何用TypeScript和Inngest构建持久的智能体系统。

想象一下,你是一个赛车手,开着一辆超炫的跑车。引擎嗡嗡响,轮胎冒青烟,各种仪表盘闪烁不停。但问题来了——这些牛逼的零件是怎么连接在一起的?引擎的动力怎么传到轮子上?方向盘怎么控制前轮转向?油门刹车怎么告诉引擎该干嘛?

没错,就是那些你看不见的线路、管道、传动轴——工程师们管这叫“缰绳”(Harness)!

在工程界,缰绳无处不在:电线缰绳把发动机、传感器和仪表盘连起来;测试缰绳给代码搭个脚手架,让程序能被反复测试;安全缰绳在你掉下来的时候接住你。

AI智能体也需要这个!大语言模型(LLM)是引擎,工具是外设,记忆是存储。但问题来了——啥玩意把这些连起来?当LLM在第五轮循环突然超时挂掉,谁来抓住这个失败?啥玩意防止两条消息撞车?啥玩意把网络钩子(webhook)的事件路由到正确的处理器再到正确的回复通道?

没错,就是缰绳Harness!

而现在每个AI框架都在从头造这个缰绳Harness——自己的重试逻辑、自己的状态保存、自己的任务队列、自己的事件路由。这不就相当于每个赛车手都得自己设计电路图吗?太离谱了!

别重复造轮子了,基础设施早就搞定了

持久化、事件驱动的基础设施其实已经解决了这个问题。每次调用LLM或工具都变成一个步骤——一个可以独立重试的工作单元。如果进程在第五轮挂了,第一轮到第四轮的结果已经保存好了。事件在函数之间路由触发。并发控制防止碰撞。步骤级别的追踪让你能完整观察智能体循环的每一次迭代。

这就是基础设施当缰绳Harness!

我们搞了个叫Utah的项目——全称是“通用触发智能体缰绳”(Universally Triggered Agent Harness)——就是为了证明这点。一个能聊天的Telegram或Slack智能体,带工具、记忆、子智能体委托和完整的持久性。用最精简的TypeScript,没有框架,只有函数、步骤和事件,围绕一个标准的“思考→行动→观察”循环提供缰绳。你可以把它想象成一个持久化的、云端就绪的OpenClaw。

“通用触发”这部分很重要:不管是Telegram还是Slack的webhook,定时任务,子智能体调用,还是函数间事件——智能体根本不知道也不关心它是怎么被激活的。触发器跟工作完全解耦。明天加个Slack机器人,智能体循环一点不用改。缰绳自动搞定路由。

到底怎么工作的?让我给你拆解一下

Utah跟大多数缰绳不一样的地方在于:它是事件驱动的,并且把编排跟智能体循环解耦了。它还用了Inngest云服务来连接公共webhook和本地工作器。

来看看这个架构:Telegram或Slack的webhook打到Inngest云,一个webhook转换器把原始的HTTP数据转换成类型化的Inngest事件。一个运行在本地的工作器接收这个事件,执行智能体函数,然后触发一个回复事件,启动另一个函数通过对应平台的API发送回复回去。任何支持webhook的通信渠道(或者任何服务)都能这么玩。

工作器用的是Inngest的connect() API,它从你的本地机器(或者一个Mac mini,或者远程服务器)到Inngest云建立一条持久的WebSocket连接,完全不需要公网端点。

工作器里跑的智能体循环超级简单:就是一个带“步骤”的while循环,步骤里调用LLM和运行工具。我们用Pi的提供者接口和他们的工具,因为这俩都很好用,但你完全可以用别的。换成AI SDK、TanStack AI都行,自己写工具或者接入MCP也可以。

OpenClaw和Continue是这项目的灵感来源。它们内部都用进程内事件,所以事件和编排都在内存里处理。而Inngest本身就是一个事件驱动的编排层,所以这项目把执行和编排完全解耦了。

这种设计给缰绳带来了几个好处:

编排层通过追踪和步骤级检查提供可观测性。内置的持久化执行提供可靠性和重试能力。解耦为多玩家分布式智能体编排打下基础。事件历史记录系统内发生的一切。定时任务内置,支持cron或者定时/延迟函数。

所有这些问题都是基础设施问题,不是AI问题!

思考→行动→观察:智能体循环的核心

Utah的核心就是一个“思考→行动→观察”循环。每次迭代调用LLM,检查它想不想用工具,执行这些工具,然后把结果喂回去。关键洞察在这里:每次LLM调用和每次工具执行都是一个Inngest步骤!

来看代码(简化版):

``typescript
while (!done && iterations < config.loop.maxIterations) {
  iterations++;

  // 清理旧的工具结果,保持上下文聚焦
  pruneOldToolResults(messages);

  // 预算警告,当迭代次数快用完时提醒
  const messagesForLLM = addBudgetWarning(messages, iterations);

  // 思考:调用LLM
  const llmResponse = await step.run("think", async () => {
    return await callLLM(systemPrompt, messagesForLLM, tools);
  });

  const toolCalls = llmResponse.toolCalls;

  if (toolCalls.length > 0) {
    messages.push(llmResponse.message);

    // 行动:分别执行每个工具,每个都是独立步骤
    for (const tc of toolCalls) {
      const result = await step.run(
tool-${tc.name}, async () => {
        validateToolArguments(tool, tc);
        return await executeTool(tc.id, tc.name, tc.arguments);
      });
      // 观察:把结果喂回消息列表
      messages.push(toolResultMessage(tc, result));
    }
  } else if (llmResponse.text) {
    // 没工具调用——文本回复就是最终回复
    finalResponse = llmResponse.text;
    done = true;
  }
}
`

注意几个重点:

Inngest会自动给重复的步骤ID加索引。当step.run("think")在循环里调用十次,Inngest内部会追踪为think:0think:1等等。你完全不用自己管唯一步骤ID的事——SDK全包了。

每个步骤都可以独立重试。如果LLM API在第三轮返回500错误,Inngest只会重试那一个步骤。第一轮和第二轮的结果已经保存好了——它们不会重新执行。这就是持久化执行在做它设计好的事情,只不过这次用在了智能体循环上,而不是结账工作流。

文本回复就意味着结束。当LLM回复文本并且没有工具调用,这一轮对话就结束了。不需要明确的“结束”信号。

工具:站在巨人的肩膀上

Utah没有自己从头实现文件I/O和shell执行。它直接引入了@mariozechner/pi-coding-agent——来自OpenClaw/Pi生态的经过实战检验的工具实现:

读、写、编辑——文件操作带图片支持、二进制检测、智能截断(编辑工具对于上下文窗口限制来说简直是一大亮点)

bash——shell执行带可配置超时和输出截断

grep、find、ls——搜索和导航,尊重.gitignore规则

在这些基础上,Utah加了几个自定义工具:remember(把笔记保存到日常日志)、web_fetchdelegate_task(后面详细说)

重点在于:AI智能体的工具方案跟其他软件完全一样。用现成的库,包一层Inngest步骤,搞定!

代码示例:

typescript
import { createReadTool, createWriteTool, createBashTool, /* ... */ } from "@mariozechner/pi-coding-agent";

const tools = [
  createReadTool(config.workspace.root),
  createWriteTool(config.workspace.root),
  createBashTool(config.workspace.root),
  // ...
];

简单吧?复制粘贴,直接用!

六个函数,各司其职

Utah不是一个什么都能干的单一函数,而是六个通过事件通信的函数:

typescript
const functions = [
  handleMessage,     // 主智能体循环
  sendReply,         // 把回复发回聊天渠道
  acknowledgeMessage,// 正在输入指示——立即触发
  failureHandler,    // 全局错误处理器,处理所有函数错误
  heartbeat,         // 定时调度的心跳检查
  subAgent,          // 通过step.invoke()运行的独立子智能体
];

这种分工很重要!正在输入指示在消息到达时立即触发——它完全不等待智能体循环。回复函数处理Telegram/Slack特定的格式化和错误处理(比如当LLM生成格式错误的HTML时回退到纯文本)。失败处理器捕获所有函数中未处理的错误,并通知用户。

每个函数都有自己的重试策略、并发控制和触发条件。这在Inngest里很自然——你就是在用事件连接的小型、专注的函数来组合行为。

那个sendReply函数呢?它可以从任何地方触发。所以如果我们想让子智能体或者分叉的工作流发送中间回复来更新用户,只要在新工具里发送事件就行了。

子智能体:分身术大法

有时候智能体需要完成的任务太大,会把上下文窗口撑爆——比如重构一个文件、研究一个主题、写一份文档。用OpenClaw这类通用智能体在单线程对话里跑几天,上下文窗口真的会爆炸。解决方案就是:召唤一个子智能体!

Utah有一个delegate_task工具。当主智能体调用它时,用step.invoke()启动一个完全独立的智能体函数运行。子智能体会把会话的上下文分叉到自己的子会话中(用自己的子会话密钥),专注于特定任务,然后返回结果:

`typescript
// 在主智能体循环里,当调用delegate_task时:
const subResult = await step.invoke("sub-agent", {
  function: subAgent,
  data: {
    task: tc.arguments.task,
    subSessionKey:
sub-${sessionKey}-${Date.now()},
  },
});
`

子智能体函数用自己的上下文窗口运行一个全新的智能体循环,使用同样的工具(除了delegate_task——不允许递归召唤),然后给父智能体返回一个总结:

typescript
// 简化版子智能体
export const subAgent = inngest.createFunction(
  { id: "agent-sub-agent", retries: 1 },
  { event: "agent.subagent.spawn" },
  async ({ event, step }) => {
    const { task, subSessionKey } = event.data;
    const agentLoop = createAgentLoop(task, subSessionKey, {
      tools: SUB_AGENT_TOOLS, // 没有delegate_task
      isSubAgent: true,
    });
    return await agentLoop(step);
  }
);

这就是step.invoke()在做它设计好的事——作为步骤调用另一个Inngest函数,等它返回结果,然后继续。子智能体有自己的重试机制、自己的步骤级可观测性、自己的持久化执行。父智能体看到的只是一个工具结果:“这是我干的。”

编排全搞定。不需要什么智能体对智能体的协议。就是函数调用函数!

会话管理:别让智能体打架

每个“渠道”(比如Slack)用渠道特定的会话密钥来定义什么是“会话”。对于单线程的渠道,比如Telegram,就是聊天ID;对于带线程的平台,比如Slack,就是渠道和线程的组合。

如果在一个会话里连续发多条消息,你肯定不希望第一个智能体循环一直跑,然后第二个再来回应——你希望智能体能理解两条消息的上下文。所以你要么取消第一个循环,让第二个循环处理,要么在循环里处理“转向”。在这个项目里,我们认为取消+重启最干净,因为循环会用完整上下文重新启动。

在消息处理器函数上,我们只用一行配置就搞定了:

typescript
singleton: { key: "event.data.sessionKey", mode: "cancel" },

这里做了两件事:

基于sessionKey做单例——每个聊天同时只能有一个智能体在运行。没有竞争条件,没有交错的回复。

新消息取消当前运行——如果用户在新消息发来时智能体还在处理中,当前运行被取消,用最新消息重新开始。

传统做法里,你得给每个用户建一个队列,管理锁,自己处理取消。有了Inngest,一行配置就搞定了!

上下文管理:真正的挑战

最难的问题不是调用LLM,而是管理喂给LLM的内容!

Utah用的工具每次调用可能返回几千个字符。几轮迭代下来,对话上下文急剧膨胀,模型开始迷失方向。我们看到智能体循环不停地调用工具,就是从来不回复。

我们用两层上下文剪枝解决了这个问题:

typescript
const PRUNING = {
  keepLastAssistantTurns: 3,
  softTrim: { maxChars: 4000, headChars: 1500, tailChars: 1500 },
  hardClear: { threshold: 50_000, placeholder: "[工具结果已清除]" },
};

旧的工具结果会被软剪枝(保留头部+尾部),或者当总上下文太大时完全清除(硬清除)。最后三轮迭代永远保留完整。

在此基础上,还有个单独的会话压缩系统——当预估的令牌数超过阈值时,对话历史会在喂给下一轮运行前被总结。剪枝处理运行中的上下文,压缩处理跨运行的累积。

我们还加了预算警告——当智能体迭代次数快用完时注入系统消息,告诉它该收尾了。还有溢出恢复:如果LLM在运行中返回“上下文太大”错误,我们强制压缩消息,在不浪费迭代的情况下重试。通过剪枝、压缩、预算压力和溢出恢复,智能体能一直保持正轨。

多模型支持:想换就换

Utah不直接调用Anthropic SDK,而是用pi-ai,这是一个不依赖具体模型的LLM抽象层,支持Anthropic、OpenAI和Google。切换模型就是改个配置:

typescript
llm: {
  provider: "anthropic", // 或者 "openai" 或者 "google"
  model: "claude-sonnet-4-20250514",
},

面向未来,这也会很有意思——说不定子智能体可以用不同的模型,甚至来自不同的提供商。写代码的子智能体可以用Codex,搞研究的子智能体可以用Opus。这方面还有很多可以探索的!

转向问题:未解的难题

当用户在新消息发来时智能体还在运行中,该怎么办?我们用单例模式——当前运行被取消,新运行启动。这样可行,但任何正在进行的工作都会丢失。新运行从已保存的会话状态继续,但不是无缝的。这是我们正在积极探索的领域。

流式更新:未来的机会

每个Inngest步骤都是原子的——它运行、产生结果、结果被保存。这个项目目前还没有包含流式功能。Telegram和Slack支持单独事件,但我们想给这项目加个网页应用和终端UI,探索怎么给支持流式的客户端发送运行中进度更新。未来迭代会有的!

从单人模式到多人模式

Utah是一个个人单玩家缰绳,运行在你的本地机器或服务器上。核心架构其实可以支持更多。接下来几周,我们正在探索怎么让Utah真正支持多人模式。

要实现多人模式,我们要研究可替换的沙箱、外部状态和记忆。这样如果有人想的话,Utah就能运行在无服务器环境了。

还有很多有趣的用户体验想加——基于Inngest API和我们的Insight功能,给编程会话建个会话监控。我们还会探索用step.waitForEvent()`创建“人在循环中”的审批流程,当需要更多输入时用。

最后,为了让这项目真正实现“通用触发”,我们还在探索让Utah能自己写自己——创建新智能体和工作流、新建webhook、通过API自我监控。如果你感兴趣,去GitHub仓库分享点想法!

来试试吧!

Utah的源代码已经作为参考实现发布了!地址在:github.com/inngest/utah

它包括:

  • 带Inngest步骤和pi-ai的模型无关LLM层的智能体循环
  • 来自pi-coding-agent的工具(读、写、编辑、bash、grep、find、ls)加上自定义工具
  • 通过step.invoke()的子智能体委托
  • 通过Inngest webhook转换器的Telegram和Slack webhook集成
  • 上下文剪枝、压缩和溢出恢复
  • 会话感知的单例并发控制

快去GitHub仓库试试!

智能体循环模式适用于任何对话式AI——Slack机器人、Discord机器人、客服智能体、编程助手。

加新渠道就是一个webhook转换器加一个回复函数的事!

如果你在构建AI智能体时也撞到同样的墙——状态管理、重试、并发、可观测性——你需要的基础构件可能已经存在了!