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

搜索系统与代理搜索
搜索系统历来都是为检索而优化的:给定一个查询,返回最相关的文档。当用户意图从获取信息转向解决问题时,这种模型就会失效。
考虑这样一个查询:
“明天西雅图的天气会如何影响飞往JFK的机票价格?”
这不是搜索问题,而是推理问题——需要拆解、在多个系统之间编排,并合成为一个连贯的答案。
这正是 代理搜索 发挥作用的地方。
在本文中,我将逐步讲解我们如何在 Go 语言中设计并投入生产使用一个代理搜索框架——不是演示,而是一个在延迟、成本、并发和故障模式等生产约束下运行的真实系统。
从搜索到代理搜索
关键词和向量搜索系统擅长将查询与文档匹配。但它们不擅长处理:
- 多步推理
- 工具协同
- 查询分解
- 答案合成
代理搜索将大语言模型视为 规划器——一个决定为回答问题应采取何种行动的组件,而不是仅仅作为文本生成器。
从高层来看,代理系统必须能够:
- 理解用户意图
- 决定调用哪些工具
- 安全执行这些工具
- 必要时进行迭代
- 合成最终响应
难点不在于将大语言模型接入工具,而在于在生产环境中以可预测且经济的方式实现这一过程。
高级架构
我们围绕三个核心关注点构建系统:
| 关注点 | 责任 |
|---|---|
| 规划 | 决定要做什么 |
| 执行 | 高效运行工具 |
| 合成 | 生成最终答案 |
以下是端到端流程:
User Query → Planner → Tool Registry → Tool Execution → Response Generator → SSE Stream → User
每个阶段都被刻意隔离。推理不会泄漏到执行中,执行也不会直接影响规划决策。
Flow Orchestrator:控制平面
The Flow Orchestrator 管理请求的完整生命周期。其职责包括:
- 协调规划器调用
- 并发执行工具
- 处理重试、超时和取消
- 流式传输部分响应
与线性流水线不同,编排器使用 Go 的 goroutine 支持并行执行。当涉及多个独立工具时,这一点变得至关重要。
查询规划器:强制首次通过,条件迭代
查询规划器始终至少被调用一次。
第一次规划调用(始终)
在第一次调用时,规划器会:
- 分析用户查询
- 生成初始的工具调用集合
- 建立一致的推理基线
即使是微不足道的查询也会经过此步骤,以保持行为的一致性和可观测性。
轻量级分类器门
在第二次调用规划器之前,我们运行一个轻量级分类器模型,以确定查询是:
- 单步
- 多步
此分类器故意设计为低成本且快速。
第二次规划调用(仅针对多步查询)
如果查询被分类为多步:
- 再次调用规划器。
- 它接收:
- 原始用户查询
- 第一次执行的工具响应
- 它确定:
- 是否需要更多工具
- 接下来调用哪些工具
- 如何对它们进行排序
这可以防止失控的规划循环——这是代理系统中最常见的故障模式之一。
工具注册表:推理与现实的交汇
每个工具都实现了严格的 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 调用成本高 — 在大规模时缓存不是可选的
- 语义缓存 立即见效
- 规划器循环 必须受限
- 大多数查询 比看起来更简单
- 工具会失败 — 重试和回退很重要
- 可观测性 是不可谈判的
- 代理并非自主 — 编排胜过自主
