简 单 , 不 简 陋 。

ezloop

一个 Loop · 两个节点 · 七个钩子 —— 用节点装饰器与流式插件组装任意智能体
极简引擎
引擎只负责流转,不理解任何具体能力
一切皆插件
重试、MCP、审批、会话,全部是 Warp / Hook
状态即消息
LoopState.Messages 可序列化、可恢复
流式 + 异步
SSE chunk 事件、RunAsync 双轨 API
$ go getgithub.com/xuanlv2002/ezloop
▼ SCROLL

01设计理念

大多数 Agent 框架把重试、MCP、审批、日志焊死在引擎里,能力越多引擎越重。ezloop 反其道而行:引擎不理解任何具体能力,它只负责流转。

概念关注点扩展方式
model节点本身:模型调用实现 provider.ModelProvider + WithModelWarp 中间件
tool节点本身:工具执行实现 types.Tool + WithToolWarp 中间件
hook流的前后:生命周期与控制流实现 hook 小接口 + WithHooks 插入

一个 Loop 的解剖

七个 hook(黄)挂在循环的节点外,按时机触发;两个节点(青 / 绿)被各自的 warp 壳(紫虚线框)包住——引擎只负责这条流转,其余全是扩展。

flowchart TD
    IN(["Run(input)"]):::misc --> SH
    SH["① OnStart<br/>注册工具 · 修理历史<br/>组装期一次"]:::hook --> MS
    EH["⑦ OnEnd<br/>成败都跑 · defer 语义"]:::hook --> OUT(["LoopState · Messages"]):::misc

    subgraph LOOP["⭕ loop 循环 · 每轮一个 iteration"]
        MS["② OnModelStart<br/>可置 Stop 提前终止"]:::hook --> MW
        subgraph MN["model 节点 · warp 壳"]
            MW["modelretry"]:::warp --> MC["model 调用"]:::model
        end
        MC --> ME["③ OnModelEnd"]:::hook
        ME --> DEC{"发起 tool_call?"}:::misc
        DEC -->|"无 → 完成"| EXIT["StopReason = completed"]:::misc
        DEC -->|"有 · N 个独立并发"| TS["④ OnToolStart<br/>Proceed / Skip / Abort"]:::hook
        subgraph TOOLS["工具段 · 每个调用是独立单元"]
            TS --> TW
            subgraph TN["tool 节点 · warp 壳"]
                TW["limit · safetool"]:::warp --> TC["tool 执行"]:::tool
            end
            TC --> TE["⑤ OnToolEnd<br/>可改写结果入史"]:::hook
        end
        TE --> LH["⑥ OnLoop<br/>回边守卫"]:::hook
        LH -->|"下一轮"| MS
        EXIT --> EH
    end

    classDef hook fill:#291f06,stroke:#fbbf24,color:#fde68a,stroke-width:1.5px
    classDef warp fill:#1d1636,stroke:#a78bfa,color:#ddd3fc,stroke-dasharray:5 3
    classDef model fill:#072331,stroke:#00add8,color:#9be5f7
    classDef tool fill:#07281b,stroke:#34d399,color:#a7f3d0
    classDef misc fill:#0b1322,stroke:#33496e,color:#c4cede
    style LOOP fill:#0a101d,stroke:#33496e,stroke-dasharray:6 4
    style MN fill:transparent,stroke:#a78bfa,stroke-dasharray:4 3
    style TN fill:transparent,stroke:#a78bfa,stroke-dasharray:4 3
    style TOOLS fill:transparent,stroke:#263449
model ↔ tool 循环:无 tool_call 即完成;每次工具调用是独立单元(判定 → warp 壳执行 → 后处理),并发跑完整链后按原序入史。
节点与流分离model / tool 是循环的节点,用 Warp 装饰(怎么执行);hook 是循环的切面,按时机插入(什么时候做什么)。两者互不越界。
扩展永不入核重试是 warp,MCP 是 hook,审批是 hook,会话持久化也是 hook。引擎零扩展依赖,不用不引入。
状态即消息loop 的全部状态是 LoopState,其中 Messages 可序列化、可恢复、可直接作为下一轮历史。没有隐藏的内存中间态。
并行分身(fork):需要提速与压缩上下文时,不是雇佣一个陌生 subagent,而是 fork 同一个自我——引擎原语 core.Agent.Fork 复刻当前 Agent(provider / warp / 超参 / 全部运行期 hook) 与上下文快照并行干活,过程不回流主上下文、只回传最终结果;事件与 session 按 ForkID 区分归属。官方 ext/hook/task 工具开箱即用。

02快速开始

十行代码跑起来 —— OpenAI 兼容接口(DeepSeek / SiliconFlow / vLLM / Ollama 通用)。

main.go
agent := core.NewAgent(p,
    core.WithSystemPrompt("你是一个严谨的助手"),
    core.WithModelWarp(modelretry.Warp()),   // 模型节点:重试
    core.WithToolWarp(limit.Warp(4), safetool.Warp()), // 工具节点:并发闸 + 防护
    core.WithHooks(filetools.New(fsys)),      // 流:文件工具
    core.WithStreaming(true),
)

// 同步单轮
state, err := agent.Run(ctx, "帮我读一下 hello.txt")

// 多轮:上一轮 Messages 直接作为历史(system 自动过滤,不重复注入)
state, err = agent.Run(ctx, "再总结一下", core.WithHistory(state.Messages...))

流式与异步(服务端场景)

async.go
h := agent.RunAsync(ctx, "长任务")
defer h.Cancel()                      // per-request 取消传播
for e := range h.Events() { render(e) } // loop 结束自动 close;channel 天然并发安全
state, err := h.Wait()

// 或回调式(CLI 直渲):实现必须并发安全且快速返回
core.WithOnEvent(func(e event.Event) {
    switch e.Type {
    case event.EventModelChunk:     print(e.Data.(string)) // 正文增量
    case event.EventReasoningChunk: print(e.Data.(string)) // 思考增量
    }
})
完整示例:examples/chat 集成全部官方能力(流式渲染节流 / 工具审批 / 分身 task / 会话持久化恢复), cp .env.example .env && go run ./examples/chat 直接体验。

03详细介绍 · 钩子(Hook)

hook 是横向切面:挂在循环的节点前后,拿得到完整 state —— 拦截、注入、改写、收尾。

接口时机典型用途
StartHookRun 开始(组装期,一次)注册工具(mcp/filetools/task)、修理历史(contextfix)
ModelStartHook每次模型调用前置 state.Stop 提前终止、上下文预压缩
ModelEndHook模型调用后响应审计、用量记账
ToolStartHook每个工具调用前(跨调用并发)审批拦截、权限判定
ToolEndHook调用后、入史前(跨调用并发)改写结果(offload 卸载大结果)
LoopHook迭代回边max-iteration 守卫、配置热加载
EndHookRun 结束(defer 语义,成败都跑)会话快照(localsession)、摘要(summary)

短路语义:Action

ToolStartHook 返回 Action——决策与结果是一体的,不分离两处:

action.go
return hook.Proceed, nil            // 正常执行
return hook.Abort, nil              // 终止整个 loop(StopReason = aborted)
return hook.Skip("denied by policy"), nil // 跳过本次调用,文案作为工具结果入史,循环继续
引擎不变量:任何退出路径(完成 / skip / abort / hook 错误 / 取消)下,每个 tool_call 必有结果消息——发给模型的序列永远协议完整,持久化恢复无需理解断点。

自定义 hook:参数级审批

guard.go
type Guard struct{}

func (Guard) Name() string { return "guard" }

func (Guard) OnToolStart(_ context.Context, _ *types.LoopState, call *types.ToolCall) (hook.Action, error) {
    if call.Name == "terminal" {
        var a struct{ Command string `json:"command"` }
        _ = json.Unmarshal(call.Args, &a)
        if !isReadOnly(a.Command) {
            return hook.Skip("terminal: read-only commands only"), nil
        }
    }
    return hook.Proceed, nil
}

agent := core.NewAgent(p, core.WithHooks(Guard{})) // 实现了哪个接口就挂到哪个时机
两条时序规则:① 同一作用点多个 hook 按注册序执行,先注册先跑;② 除 OnToolStart / OnToolEnd (跨调用并发,多个人工审批同时呈现)外,引擎串行调用全部回调——回调内可放心读写 state, 并发回调里写共享数据须自行加锁。 fork 分身全继承运行期 hook(审批无旁路、分身可问用户),组装期 hook 不重跑。

04详细介绍 · Warp(节点装饰器)

warp 是纵向封装:包住节点本身(在节点内部),管「怎么执行」——重试、防护、限流、缓存。它拿不到 state,只发出观察事件。

Hook(横向切面)Warp(纵向封装)
位置节点外(前后)节点内(包住节点)
能拿到完整 LoopStateper-Run event.Emitter(只观察)
典型审批、注入、持久化重试、panic 防护、并发闸
warp.go
// 装饰器签名:组装时注入 per-Run 事件出口(类比 net/http middleware)
type Handler[T any] func(em event.Emitter, node T) T

// 洋葱序:先注册的在外层 —— 请求外→内,结果内→外
core.WithToolWarp(safetool.Warp(), limit.Warp(4))
// safetool 在外层才能捕获 limit 与工具本体的 panic

// 自定义:跨全部工具共享的计时 warp(状态在工厂闭包里,per-Run 组装)
func Timing() warp.ToolHandler {
    var mu sync.Mutex
    total := time.Duration(0)
    return func(_ event.Emitter, inner types.Tool) types.Tool {
        return toolFunc(func(ctx context.Context, args json.RawMessage) (string, error) {
            start := time.Now()
            out, err := inner.Invoke(ctx, args)
            mu.Lock(); total += time.Since(start); mu.Unlock()
            return out, err
        }, inner)
    }
}
挂载点是 ToolRegistry:静态注册(WithTools)与 hook 运行时注入的工具(mcp 等)都会被包装; warp 实例 per-Run 独立,状态不跨 Run 共享。modelretry 默认裸引擎不内置——生产建议必挂。

05详细介绍 · 官方扩展能力

所有能力都在 ext/ 层,按需引入 —— 不用不依赖,官方 SDK 依赖不进框架层。

扩展类型说明
ext/fs底座唯一 FileSystem 接口(Read/Write/List/Edit);Local 实现(root 沙箱、查找替换)
ext/provider/openaimodelOpenAI 兼容 Provider(Invoke + SSE 流式 + reasoning 思考流),兼容 DeepSeek / SiliconFlow / vLLM / Ollama;缓存命中双协议解析
ext/warp/model/modelretrywarp模型重试:指数退避,流式仅在未发出 chunk 时重试(生产必挂)
ext/warp/tool/limitwarp工具并发闸:跨全部工具共享信号量,限制一轮 fan-out 实际并发
ext/warp/tool/safetoolwarppanic 恢复 + error 附加上下文(注册在 limit 外层)
ext/hook/mcphookmcpRouter 单工具封装:schema 恒定(KV cache 友好)、配置热加载,内置官方 go-sdk
ext/hook/skillhook技能注入:代码定义或从 FS 目录加载 *.md(全程单条 system,不滚雪球)
ext/hook/summaryhook会话摘要:阈值跳过短会话;也可直接调 Summarize 按需触发
ext/hook/approvehook工具审批:channel 决策中断(EventRequest + Decision 回传)
ext/hook/askuserhookask_user 工具:模型提问中断等回答
ext/hook/taskplanhooktask_plan 工具:规划提交中断等处置(执行/否决/修订)
ext/hook/taskhook并行分身:基于 core.Fork 复刻当前 Agent 独立跑子循环,只回传最终答案;事件与 session 按 ForkID 区分,单层防递归
ext/hook/contextfixhookRun 开始修理历史:补缺失 tool 结果、删孤儿 tool 消息(resume 旧存档防悬空)
ext/hook/offloadhook大结果卸载:超阈值写 FS,上下文只留摘要+路径
ext/hook/filetoolshook文件四件套(read/write/edit/terminal,系统原生终端+OS 提示注入),恒注册
ext/hook/localsessionhook会话持久化:滚动快照,Load/List 恢复续聊;分身写独立文件只存增量

人机交互三件套:同一套 channel 决策模式

interrupt.go
approver, approveCh := approve.New(needsFunc)  // 需要审批的调用返回 true
asker, answerCh   := askuser.New()
planner, planCh   := taskplan.New()

core.NewAgent(p, core.WithHooks(approver, asker, planner), ...)

// hook 在工具调用前阻塞等决策;渲染层在 OnEvent 里呈现请求、
// 从独立 goroutine 回传 channel(同步回传会死锁)。
core.WithOnEvent(func(e event.Event) {
    if e.Type == approve.EventRequest {
        call := e.Data.(*types.ToolCall) // 带 CallID 与 ForkID(分身在问也一样工作)
        go send(ctx, approveCh, approve.Decision{CallID: call.ID, Approve: askHuman(call)})
    }
})

并行分身:task 工具

fork.go
core.NewAgent(p, core.WithHooks(task.New()), ...) // 一步到位,task 工具自动注册

// 主模型对可并行的子任务自动调用 task:
//   同一轮多个 task 并行 fork(工具本就并发)
//   分身复刻当前 Agent 与上下文快照,过程隔离,只回传最终答案
//   审批照拦、可问用户;事件带 ForkID(⟨task-N⟩),session 独立可回放
//   单层:分身不能再 fork

06详细介绍 · 本地 Agent 开发:事件与上下文管理

CLI / 桌面场景:回调直渲、stdin 桥接人机中断、本地 session 文件持久化与恢复。

事件全景(回调消费)

事件时机Data
model_chunk / reasoning_chunk流式正文 / 思考增量string
tool_start / tool_end工具调用起止(随调用即时,以 CallID 关联)*ToolCall / *ToolResult
iteration_end每轮迭代结束int
error / stream_fallback引擎错误 / 流式降级警告error / string
task.start / task.end分身起止*ToolCall / *LoopState
approve.request 等人机交互请求(带 CallID)*ToolCall 等
所有事件带 ForkID:空串=主循环,非空=对应分身——渲染层据此打 ⟨task-N⟩ 行首标记,把分身的流式输出与提问路由到正确位置。

流式渲染节流

render.go
// 碎 delta(每次一两个字符)高频直写终端开销大(Windows console 同步写尤其贵)——
// 攒缓冲定期整块写出:一次系统调用代替上百次。80ms tick 足够顺滑。
sb := &streamBuf{}
ticker := time.NewTicker(80 * time.Millisecond)
go func() { for range ticker.C { sb.flush() } }()
// 提示类输出用 sb.now(s):并入缓冲后立即整块写出,与流式内容严格有序

上下文管理与会话恢复

session.go
fsys := fs.NewLocal(".")                    // 工作区沙箱 = 当前目录
session := localsession.New(fsys, "")          // 每轮结束滚动快照 sessions/<id>.json

// 恢复 = 新对话:加载历史 + 新输入,模型看历史自己重发调用。
// system 是 agent 属性,WithHistory 自动过滤不重复注入;
// 旧存档的悬空 tool_call 由 contextfix 兜底修理。
s, _ := localsession.Load(ctx, fsys, "", id)
state, _ := agent.Run(ctx, input, core.WithHistory(s.Messages...))

// 分身过程同样可回放:sessions/<主ID>-task-1.json,只存增量
大结果防溢出:offload hook 把超阈值工具输出卸载到 FS(文件名=工具名+内容哈希,幂等),上下文只留摘要+路径; 推理过程同理——reasoning 持久化在消息里供回放,但不回传给模型(协议要求)。

07详细介绍 · 多租户 Agent 开发:事件与上下文管理

Web / 服务端场景:一个 Agent 定义、N 个并发会话——事件流、取消、会话按请求隔离。

共享构建,按请求运行

server.go
// Agent 构建后只读是框架契约:全局单例,并发 Run 安全,
// 每次运行的全部状态在独立的 LoopState 里,互不可见。
agent := core.NewAgent(p, ...) // 进程启动时组装一次

func handle(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithCancel(r.Context())
    defer cancel()                       // 客户端断连 → 只取消本次 loop

    // 按请求恢复历史(会话归属在业务层:URL/租户头 → session ID)
    h := agent.RunAsync(ctx, input, core.WithHistory(load(r)...))
    defer h.Cancel()

    for e := range h.Events() {         // 每请求独立事件通道,天然隔离
        switch e.Type {
        case event.EventModelChunk:
            writeSSE(w, e.ForkID, e.Data.(string)) // ForkID 非空 = 分身输出,路由标记
        case approve.EventRequest:
            pushCard(w, e.Data.(*types.ToolCall)) // 渲染审批卡片,用户点击后回传
        }
    }
    state, _ := h.Wait()
    save(r, state.Messages)
}

隔离清单

维度做法
事件流RunAsync 的 Events() 每请求独立;全局回调式 OnEvent 只做度量,不做分发
取消请求 ctx 断连 → h.Cancel(),只取消该 loop,共享 Agent 不受影响
会话存储每租户一个 localsession 实例(或 WithDir 按租户分目录);分身自动写独立文件
人机交互EventRequest 带 CallID → 前端卡片 → 用户操作从任意 goroutine 回传 Decision channel(select ctx.Done 防悬挂)
并发边界OnToolStart/OnToolEnd 跨调用并发——hook 写共享数据自行加锁;OnEvent 回调必须并发安全且快速返回
分身即并发加速:多租户下 task 分身照常工作——分身的流式 chunk、审批请求都带 ForkID, 服务端把它路由到发起请求的那条 SSE 流;分身用量自动累加回主循环,账单不漏。