超越关键词:在 Go 中打造 Production-Ready Agentic Search Framework

发布: (2025年12月29日 GMT+8 18:00)
9 min read
原文: Dev.to

Source: Dev.to

超越关键词:在 Go 中构建生产就绪的代理搜索框架的封面图

Amit Surana

搜索系统与代理搜索

搜索系统历来都是为检索而优化的:给定一个查询,返回最相关的文档。当用户意图从获取信息转向解决问题时,这种模型就会失效。

考虑这样一个查询:

“明天西雅图的天气会如何影响飞往JFK的机票价格?”

这不是搜索问题,而是推理问题——需要拆解、在多个系统之间编排,并合成为一个连贯的答案。

这正是 代理搜索 发挥作用的地方。

在本文中,我将逐步讲解我们如何在 Go 语言中设计并投入生产使用一个代理搜索框架——不是演示,而是一个在延迟、成本、并发和故障模式等生产约束下运行的真实系统。

从搜索到代理搜索

关键词和向量搜索系统擅长将查询与文档匹配。但它们不擅长处理:

  • 多步推理
  • 工具协同
  • 查询分解
  • 答案合成

代理搜索将大语言模型视为 规划器——一个决定为回答问题应采取何种行动的组件,而不是仅仅作为文本生成器。

从高层来看,代理系统必须能够:

  • 理解用户意图
  • 决定调用哪些工具
  • 安全执行这些工具
  • 必要时进行迭代
  • 合成最终响应

难点不在于将大语言模型接入工具,而在于在生产环境中以可预测且经济的方式实现这一过程。

高级架构

我们围绕三个核心关注点构建系统:

关注点责任
规划决定要做什么
执行高效运行工具
合成生成最终答案

以下是端到端流程:

User Query → Planner → Tool Registry → Tool Execution → Response Generator → SSE Stream → User

每个阶段都被刻意隔离。推理不会泄漏到执行中,执行也不会直接影响规划决策。

Flow Orchestrator:控制平面

The Flow Orchestrator 管理请求的完整生命周期。其职责包括:

  • 协调规划器调用
  • 并发执行工具
  • 处理重试、超时和取消
  • 流式传输部分响应

与线性流水线不同,编排器使用 Go 的 goroutine 支持并行执行。当涉及多个独立工具时,这一点变得至关重要。

查询规划器:强制首次通过,条件迭代

查询规划器始终至少被调用一次。

第一次规划调用(始终)

在第一次调用时,规划器会:

  • 分析用户查询
  • 生成初始的工具调用集合
  • 建立一致的推理基线

即使是微不足道的查询也会经过此步骤,以保持行为的一致性和可观测性。

轻量级分类器门

在第二次调用规划器之前,我们运行一个轻量级分类器模型,以确定查询是:

  • 单步
  • 多步

此分类器故意设计为低成本且快速。

第二次规划调用(仅针对多步查询)

如果查询被分类为多步:

  1. 再次调用规划器。
  2. 它接收:
    • 原始用户查询
    • 第一次执行的工具响应
  3. 它确定:
    • 是否需要更多工具
    • 接下来调用哪些工具
    • 如何对它们进行排序

这可以防止失控的规划循环——这是代理系统中最常见的故障模式之一。

工具注册表:推理与现实的交汇

每个工具都实现了严格的 Go 接口:

// ToolInterface is the tool interface for developers to implement which uses
// generics with strongly typed
type ToolInterface[Input any, Output any] interface {
    // Execute initiates the execution of a tool.
    //
    // Parameters:
    // - ctx:        Context for cancellation/timeout.
    // - requestContext: Additional request‑specific data.
    // - input:      Strong‑typed tool request input.
    // Returns:
    // - output:     Strong‑typed tool request output.
    // - toolContext: Additional output data not used by the agent model.
    // - err:        Structured error from tool (e.g., no_response).
    Execute(ctx context.Context, requestContext *RequestContext, input Input) (output Output, toolContext ToolResponseContext, err error)

    // GetDefinition gets the tool definition sent to the Large Language Model.
    GetDefinition() ToolDefinition
}

此设计为我们提供了:

  • 用于规划器反馈的自然语言输出
  • 用于下游使用的结构化元数据
  • 编译时安全性
  • 安全的并行执行

工具注册表充当信任边界。规划器的输出被视为 intent,而非直接指令。

并行工具执行

Planner 生成的工具调用在可能的情况下会并发执行。Go 的并发模型使这成为可能:

  • 轻量级 goroutine
  • 基于上下文的取消
  • 高效的 I/O 绑定执行

这是 Go 在代理系统超出原型阶段时比 Python 更具可扩展性的原因之一。

响应生成与流式传输

工具完成后,响应流入响应生成器。

  • 基于知识的查询 使用大型语言模型(LLM)进行摘要和合成。
  • 直接回答查询(天气、体育、股票)跳过合成,返回原始工具输出。

响应通过 Server‑Sent Events (SSE) 进行流式传输,使用户能够提前看到部分结果,提升感知延迟。

缓存策略:让自主搜索更经济

几乎立刻就清晰了一个生产现实:LLM 调用确实有成本——包括延迟和金钱。

当我们开始提供 beta 流量时,缓存变得必须。我们的指导原则很简单:尽可能避免 LLM 调用。

第 1 层:语义缓存(完整响应)

我们首先检查基于用户查询键入的语义缓存。

  • 缓存命中 → 立即返回响应
  • 缓存未命中 → 继续到下一层

命中时会跳过整个自主流程,带来最大的延迟和成本收益。

第 2 层:规划器响应缓存

如果语义缓存未命中,我们检查规划器输出(工具计划)是否已缓存。

  • 缓存命中 → 跳过规划器 LLM 调用,直接执行工具
  • 缓存未命中 → 调用规划器 LLM

规划器调用是成本最高且波动最大的操作之一——缓存它们可以稳定延迟和费用。

第 3 层:摘要器缓存

最后,我们缓存摘要器的输出。

  • 工具结果经常重复
  • 最终合成可以复用
  • 在流量高峰期间降低 LLM 负载

每一层缓存都能短路管道中的不同环节。

生产中的经验教训

  • LLM 调用成本高 — 在大规模时缓存不是可选的
  • 语义缓存 立即见效
  • 规划器循环 必须受限
  • 大多数查询 比看起来更简单
  • 工具会失败 — 重试和回退很重要
  • 可观测性 是不可谈判的
  • 代理并非自主 — 编排胜过自主
Back to Blog

相关文章

阅读更多 »