2026年8月2日 2 分钟阅读

Go Agent 运行时别把事件拆散:用 GAI 保住工具调用、重试与流式输出的因果顺序

tinyash 0 条评论

给 Go 服务接入大模型,最容易写出来的是一次性的 Generate 调用;最难维护的却是一个真正的 Agent 循环:模型一边流式输出,一边请求本地工具,工具可能失败,模型还可能重试或取消。此时如果把 token、工具状态和错误分别塞进几个 channel,调用方很容易无法判断「这一段文字属于哪一次尝试」「重试后先前输出是否应撤回」,更别说把一次运行接到日志与追踪系统。

GAI(lace-ai/gai)是一个仍处于 pre-v1 阶段的 Go Agent 运行时。它采用 MIT 许可证,目标不是接管应用,而是在模型调用、工具循环、上下文、历史和观测之间提供可组合的类型化边界。项目当前提供 Anthropic、Gemini、Mistral 与 OpenAI 适配器;应用代码面向共享接口,而保留各 Provider 在原生工具、结构化输出、推理控制和消息历史方面的能力。

这篇文章不把 GAI 当成「换一个 SDK」来介绍,而是聚焦一个更具体的问题:当 Agent 的回复需要经过工具调用和重试时,怎样让 UI、审计记录与业务流程看到同一条有因果关系的事件序列。

为什么多个 channel 会让 Agent 运行变得含糊

一个典型的 Agent 运行至少包含四类信号:模型生成的 token、工具调用及结果、重试或取消、最终完成或错误。若分别用 tokensstatuserrors 三个 channel 暴露,消费者必须并发 drain 它们,还要自行补齐跨 channel 的先后关系。

这在简单聊天中似乎无伤大雅,但工具循环会放大问题。假设模型先生成一段「正在查询订单」,随后发起工具调用;工具返回异常,运行时重试了模型请求。前端若已经把第一轮 token 固化,用户就会看到与最终答案矛盾的文字。审计系统若只有工具日志,也无法可靠地把该调用归属到哪次模型尝试。

GAI 把这种顺序作为运行时接口的一部分:主 Agent 推荐消费 Workflow.RunEvents。事件流中同时有开始尝试、token、重试、一次迭代完成、完成、错误和取消等事件。重试事件可关联尝试标识,消费者因而能在 UI 中撤回对应尝试的临时输出,或在审计中完整记录「生成—工具—重试—收敛」的链路。这里的重点不是事件数量,而是只消费一条有序流。

先用最小程序验证模型与事件循环

GAI 要求 Go 1.26.1 或更高版本。先把模块加到已有服务,而不是急着重构全部 Provider 封装:

go get github.com/lace-ai/gai
export OPENAI_API_KEY="你的密钥"

下面的程序使用 OpenAI 适配器创建模型和 Agent,并只在收到文本 token 时向标准输出写入内容。它同时处理错误和取消,让命令行演示不会把「没有正常结束」误判为成功。

package main

import (
    "context"
    "fmt"
    "log"
    "os"

    "github.com/lace-ai/gai/agent"
    "github.com/lace-ai/gai/ai"
    "github.com/lace-ai/gai/ai/openai"
    gaictx "github.com/lace-ai/gai/context"
    "github.com/lace-ai/gai/loop"
)

func main() {
    ctx := context.Background()
    provider := openai.New(os.Getenv("OPENAI_API_KEY"), nil)
    model, err := provider.Model("gpt-4.1-mini")
    if err != nil {
        log.Fatal(err)
    }
    defer model.Close()

    assistant := agent.New(agent.Definition{
        Name:  "assistant",
        Model: model,
        Prompt: func(context.Context, agent.RunInput) (gaictx.PromptBuilder, error) {
            return gaictx.New(gaictx.Definition{
                SystemInstructions: []gaictx.Part{
                    gaictx.NewTextPart("用简洁中文回答。"),
                },
            }), nil
        },
    })

    workflow, err := assistant.NewRun(ctx, agent.RunInput{
        Prompt: gaictx.PromptInput{User: gaictx.NewTextContent("解释什么是有序事件流。")},
    })
    if err != nil {
        log.Fatal(err)
    }

    var runErr error
    for event := range workflow.RunEvents(ctx) {
        switch event.Type {
        case loop.EventToken:
            if event.Token != nil && event.Token.Type == ai.TokenTypeText {
                fmt.Print(event.Token.Text)
            }
        case loop.EventRetry:
            log.Printf("attempt %s will be retried", event.AttemptID)
        case loop.EventError, loop.EventCanceled:
            runErr = event.Err
        }
    }
    if runErr != nil {
        log.Fatal(runErr)
    }
}

这里有两个实现细节值得保留。第一,agent.Definition 是可复用定义,而每次请求都应通过 NewRun 创建新的 workflow;官方 README 明确说明 workflow 是单次使用的。第二,示例没有把错误藏进 goroutine:循环结束后统一检查 runErr,避免流式文本已输出、但调用者把失败当成功返回。

工具定义要把不可信输入挡在边界上

Agent 的工具不是普通函数列表。GAI 的工具接口包含名称、描述、参数 schema 与执行函数;参数会被转换为 JSON Schema,供支持原生 function calling 的 Provider 使用。模型不支持原生工具时,GAI 还有文本协议兼容路径,但业务侧仍应把参数校验放在工具边界。

以订单查询为例,工具应只接受 order_id,并将参数解码失败直接返回工具错误,而不是把未经验证的模型输出传入数据库或 HTTP 客户端:

func (t *LookupOrderTool) Params() ai.ToolParameters {
    return ai.ToolParameters{
        Strict: true,
        Properties: []ai.ToolParameter{{
            Name: "order_id", Type: ai.ToolParameterString,
            Description: "要查询的订单编号", Required: true,
        }},
    }
}

func (t *LookupOrderTool) Function(ctx context.Context, req *ai.ToolCall) *loop.ToolResponse {
    var args struct {
        OrderID string `json:"order_id"`
    }
    if err := loop.DecodeToolArgs(req, &args); err != nil {
        return loop.NewToolError(err)
    }
    return loop.NewToolSuccess(`{"status":"in_transit"}`)
}

这段代码并不等于完整授权方案。Strict 约束的是参数 schema,不会替业务确认该用户能否读取这个订单。更稳妥的做法是将用户身份、租户和权限从可信的服务端上下文传入工具,在 Function 内核验资源归属;不要相信模型在参数里声称的身份。对会产生副作用的工具,还应在执行前加入审批、幂等键和明确的操作日志。

把工具挂入 agent.Definition.Tools 后,运行时负责把定义交给模型、执行被请求的调用、把结果写回对话,并持续迭代直到模型给出正常回复或达到迭代限制。它减少的是循环样板代码,并没有替你定义工具的授权策略。

把有序事件流变成可测试的契约

只在浏览器里看一次流式回复,无法证明事件顺序在重试时仍正确。建议把 Agent 运行记录成简化事件序列,并针对业务关心的分支写断言。例如,正常工具调用应出现「尝试开始 → 工具相关 token/调用 → 迭代完成 → 完成」;可恢复的 Provider 错误应出现 EventRetry,并且最终完成事件只出现一次;用户取消时则应当得到取消事件,而不是把半截文本当作成功答案写入会话库。

测试不必依赖真实模型。把工具实现替换为可控的本地 stub:第一轮返回一个可预期结果,第二轮故意返回解码错误或超时;事件消费者将 event.TypeAttemptID 写入切片,再比较期望的相对顺序。不要断言模型自然语言的逐字文本——那会让测试随着模型版本波动。应断言的是不变量:重试后旧尝试的临时 UI 内容可识别、工具失败有可追溯记录、最终业务写入只发生在成功完成之后。

在生产环境中也应采用同一思路。UI 可以按 AttemptID 缓存尚未确认的 token,收到重试时撤回对应尝试;日志则为每个事件附加请求 ID、受控的工具名和耗时。不要把完整 prompt、工具参数或返回值默认写进日志:它们常包含用户输入、订单信息或密钥。这样,事件流既服务于交互,也成为可审计、可回放和可测试的运行契约。

还要明确失败语义。网络瞬断、Provider 限流和可重试的 5xx 可以进入重试路径,但权限拒绝、参数 schema 不匹配、余额不足或工具返回的业务否定结果不该被无限重放。为每种工具设置有限的超时与最大尝试次数,并在超过预算后向上游返回可辨识的失败类别。对于写操作,重试前先检查幂等键或操作状态;否则「事件流有序」只能帮助你看清重复扣款、重复发信这类事故,而不能阻止它们发生。

上下文预算与历史不是“把聊天记录全塞进去”

长对话常见的失败不是模型突然变笨,而是提示词里混入了重复历史、过期状态和不该暴露的元数据。GAI 的 context 包把系统指令、动态上下文源、机器上下文、真实用户内容、对话消息和 token 预算分开建模。BuildContext 会为上下文源分配预算,BuildPrompt 则在每轮循环渲染当前输入与累计对话。

历史组件可以按会话加载持久化记录、选择装得下的近期轮次并复用 token 计数;当较早内容确实需要保留时,可使用带 summarizer 定义的历史组件。工程上应把「可重放的原始事件」「业务事实」「模型摘要」分存:摘要适合给模型续聊,不应成为审计唯一证据;订单、权限和金额等事实仍要回到权威系统查询。

同样需要克制的是 trace metadata。GAI 默认不会导出 prompt、completion、推理文本、工具参数、工具结果或元数据值;若显式启用观测,也应只发送经过脱敏、对排障有必要的字段。README 还说明其 trace context 使用内部 Go context 值,不会自动把用户或会话标识加入出站请求头。这是不错的默认值,但不替代你对 exporter、日志处理器和工具实现的敏感信息检查。

什么时候适合采用 GAI

GAI 更适合已经有 Go 服务、希望保留自己 HTTP 层、鉴权层和数据访问层的团队:你需要多个模型 Provider、工具调用循环、流式界面,并希望把重试和生命周期统一接入 OpenTelemetry。它也适合把 Agent 当成应用内一个能力,而不是把整套业务迁到某个封闭框架。

相反,如果需求只是离线批处理的一次结构化提取,直接调用 Provider SDK 往往更少依赖;如果核心难题是跨 Agent 调度或人工审批,GAI README 对 middleware 的定位是上游完成后的后处理,不应把它误当作监督或交接运行时。工具授权与审批仍应布置在工具执行边界。

采用时可以按三步渐进:先用一个只读工具和 RunEvents 跑通可观察的单轮流程;再为每类事件建立 UI/日志处理规则;最后才引入持久历史、摘要和 exporter。这样能先验证最有价值的部分——事件因果顺序——而不会在第一天就把 Agent、记忆、追踪和业务写入操作绑成难以调试的一团。

相关链接

发表评论

你的邮箱地址不会被公开,带 * 的为必填项。