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 通用)。
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...))
流式与异步(服务端场景)
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 —— 拦截、注入、改写、收尾。
| 接口 | 时机 | 典型用途 |
|---|---|---|
StartHook | Run 开始(组装期,一次) | 注册工具(mcp/filetools/task)、修理历史(contextfix) |
ModelStartHook | 每次模型调用前 | 置 state.Stop 提前终止、上下文预压缩 |
ModelEndHook | 模型调用后 | 响应审计、用量记账 |
ToolStartHook | 每个工具调用前(跨调用并发) | 审批拦截、权限判定 |
ToolEndHook | 调用后、入史前(跨调用并发) | 改写结果(offload 卸载大结果) |
LoopHook | 迭代回边 | max-iteration 守卫、配置热加载 |
EndHook | Run 结束(defer 语义,成败都跑) | 会话快照(localsession)、摘要(summary) |
短路语义:Action
ToolStartHook 返回 Action——决策与结果是一体的,不分离两处:
return hook.Proceed, nil // 正常执行 return hook.Abort, nil // 终止整个 loop(StopReason = aborted) return hook.Skip("denied by policy"), nil // 跳过本次调用,文案作为工具结果入史,循环继续
引擎不变量:任何退出路径(完成 / skip / abort / hook 错误 / 取消)下,每个 tool_call 必有结果消息——发给模型的序列永远协议完整,持久化恢复无需理解断点。
自定义 hook:参数级审批
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(纵向封装) | |
|---|---|---|
| 位置 | 节点外(前后) | 节点内(包住节点) |
| 能拿到 | 完整 LoopState | per-Run event.Emitter(只观察) |
| 典型 | 审批、注入、持久化 | 重试、panic 防护、并发闸 |
// 装饰器签名:组装时注入 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/openai | model | OpenAI 兼容 Provider(Invoke + SSE 流式 + reasoning 思考流),兼容 DeepSeek / SiliconFlow / vLLM / Ollama;缓存命中双协议解析 |
ext/warp/model/modelretry | warp | 模型重试:指数退避,流式仅在未发出 chunk 时重试(生产必挂) |
ext/warp/tool/limit | warp | 工具并发闸:跨全部工具共享信号量,限制一轮 fan-out 实际并发 |
ext/warp/tool/safetool | warp | panic 恢复 + error 附加上下文(注册在 limit 外层) |
ext/hook/mcp | hook | mcpRouter 单工具封装:schema 恒定(KV cache 友好)、配置热加载,内置官方 go-sdk |
ext/hook/skill | hook | 技能注入:代码定义或从 FS 目录加载 *.md(全程单条 system,不滚雪球) |
ext/hook/summary | hook | 会话摘要:阈值跳过短会话;也可直接调 Summarize 按需触发 |
ext/hook/approve | hook | 工具审批:channel 决策中断(EventRequest + Decision 回传) |
ext/hook/askuser | hook | ask_user 工具:模型提问中断等回答 |
ext/hook/taskplan | hook | task_plan 工具:规划提交中断等处置(执行/否决/修订) |
ext/hook/task | hook | 并行分身:基于 core.Fork 复刻当前 Agent 独立跑子循环,只回传最终答案;事件与 session 按 ForkID 区分,单层防递归 |
ext/hook/contextfix | hook | Run 开始修理历史:补缺失 tool 结果、删孤儿 tool 消息(resume 旧存档防悬空) |
ext/hook/offload | hook | 大结果卸载:超阈值写 FS,上下文只留摘要+路径 |
ext/hook/filetools | hook | 文件四件套(read/write/edit/terminal,系统原生终端+OS 提示注入),恒注册 |
ext/hook/localsession | hook | 会话持久化:滚动快照,Load/List 恢复续聊;分身写独立文件只存增量 |
人机交互三件套:同一套 channel 决策模式
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 工具
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⟩ 行首标记,把分身的流式输出与提问路由到正确位置。流式渲染节流
// 碎 delta(每次一两个字符)高频直写终端开销大(Windows console 同步写尤其贵)—— // 攒缓冲定期整块写出:一次系统调用代替上百次。80ms tick 足够顺滑。 sb := &streamBuf{} ticker := time.NewTicker(80 * time.Millisecond) go func() { for range ticker.C { sb.flush() } }() // 提示类输出用 sb.now(s):并入缓冲后立即整块写出,与流式内容严格有序
上下文管理与会话恢复
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 个并发会话——事件流、取消、会话按请求隔离。
共享构建,按请求运行
// 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 流;分身用量自动累加回主循环,账单不漏。