Hono实现Agent API

要点

  • 前面十几篇分别讲了 Agent 的各个模块——工具定义、执行器、记忆系统、错误恢复、状态管理,这篇把它们串起来,用 Hono 搭一个完整的 Agent API 服务
  • 整体架构分三层:Hono 路由接收请求、Agent Loop 驱动 LLM 与工具的交互循环、工具层执行具体操作
  • 会话管理用 KV 存储对话历史,支持多轮对话和过期自动清理
  • Agent 核心循环处理四种终止条件:LLM 给出最终回复、工具调用出错、循环次数上限、请求超时
  • 流式响应通过 SSE 把 Agent 的思考过程和工具调用状态实时推送给前端

1. 整体架构

一个完整的 Agent API 需要处理这些环节:

前端请求 → Hono 路由 → 会话管理 → Agent Loop → LLM API
                                              ↓
                              工具调用 → 工具执行器 → 外部服务
                                              ↓
                              结果回传 → 流式响应 → 前端

按职责拆成三层:

  1. 接口层(Hono 路由)— 接收 HTTP 请求,做参数校验、鉴权、限流
  2. 编排层(Agent Loop)— 驱动「LLM 推理 → 工具调用 → 结果回传」的循环
  3. 工具层(工具注册表 + 执行器)— 管理工具定义和执行逻辑

目录结构:

src/
  index.ts                  入口文件
  types.ts                  共享类型
  routes/
    agent.ts                Agent API 路由
  agent/
    loop.ts                 Agent 核心循环
    llm.ts                  LLM 调用封装
    session.ts              会话存储(KV)
    stream.ts               流式响应
    tools/
      registry.ts           工具注册表
      types.ts              工具类型
      weather.ts            天气查询工具
  middleware/
    auth.ts                 鉴权中间件

2. 类型定义

所有共享类型集中到 types.ts

// src/types.ts
export type Message = {
  role: 'system' | 'user' | 'assistant' | 'tool'
  content: string
  toolCalls?: ToolCall[]
  toolCallId?: string
}
 
export type ToolCall = {
  id: string
  name: string
  arguments: Record<string, unknown>
}
 
export type LLMResponse = {
  text: string
  toolCalls: ToolCall[]
}
 
// Agent 事件(用于流式响应)
export type AgentEvent =
  | { type: 'thinking'; content: string }
  | { type: 'tool_call_start'; toolName: string }
  | { type: 'tool_call_result'; toolName: string; result: unknown }
  | { type: 'done'; finalText: string }
  | { type: 'error'; message: string }
 
export type Session = {
  id: string
  userId: string
  messages: Message[]
  createdAt: number
  updatedAt: number
}
 
export type AppEnv = {
  Bindings: {
    OPENAI_API_KEY: string
    KV: KVNamespace
    DB: D1Database
    MAX_ITERATIONS: string
  }
  Variables: { user: { id: string; role: string } }
}

3. 会话管理

前面的文章里,messages 数组只在内存中存在,请求结束就丢失。生产环境需要持久化对话历史。KV 读写延迟低、自带过期机制,适合存储会话数据:

// src/agent/session.ts
import type { Session, Message } from '../types'
 
const PREFIX = 'session:'
const TTL = 86400 // 24 小时
 
export async function createSession(kv: KVNamespace, userId: string): Promise<Session> {
  const session: Session = {
    id: crypto.randomUUID(),
    userId,
    messages: [],
    createdAt: Date.now(),
    updatedAt: Date.now(),
  }
  await kv.put(`${PREFIX}${session.id}`, JSON.stringify(session), { expirationTtl: TTL })
  return session
}
 
export async function getSession(kv: KVNamespace, sessionId: string): Promise<Session | null> {
  return kv.get(`${PREFIX}${sessionId}`, 'json') as Promise<Session | null>
}
 
// 追加消息时续期 TTL,活跃会话不会被清理
export async function appendMessages(kv: KVNamespace, sessionId: string, newMessages: Message[]) {
  const session = await getSession(kv, sessionId)
  if (!session) return null
  session.messages.push(...newMessages)
  session.updatedAt = Date.now()
  await kv.put(`${PREFIX}${session.id}`, JSON.stringify(session), { expirationTtl: TTL })
  return session
}

会话量超过千级时,list 遍历所有 key 效率不高,建议改用 D1 存储会话索引,或按 userId 做 key 前缀缩小范围。

4. LLM 调用封装

把 LLM API 封装成独立模块,Agent Loop 只关心消息和工具,不关心具体调的是哪个提供商:

// src/agent/llm.ts
import type { Message, ToolCall, LLMResponse } from '../types'
import type { ToolDefinition } from './tools/types'
 
const TIMEOUT_MS = 30_000
 
export async function callLLM(
  messages: Message[],
  tools: ToolDefinition[],
  apiKey: string,
): Promise<LLMResponse> {
  const res = await fetch('https://api.openai.com/v1/chat/completions', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${apiKey}`,
    },
    body: JSON.stringify({
      model: 'gpt-4o',
      messages,
      tools: tools.map((t) => ({
        type: 'function' as const,
        function: { name: t.name, description: t.description, parameters: t.parameters },
      })),
      tool_choice: 'auto',
    }),
    signal: AbortSignal.timeout(TIMEOUT_MS),
  })
 
  if (!res.ok) throw new Error(`LLM API 错误 (${res.status})`)
 
  const data = await res.json() as {
    choices: Array<{ message: { content: string | null; tool_calls?: Array<{ id: string; function: { name: string; arguments: string } }> } }>
  }
 
  const msg = data.choices[0].message
  return {
    text: msg.content ?? '',
    toolCalls: (msg.tool_calls ?? []).map((tc) => ({
      id: tc.id,
      name: tc.function.name,
      arguments: JSON.parse(tc.function.arguments || '{}'),
    })),
  }
}

超时控制放在这一层。LLM 响应时间波动大,不设超时的话,一个卡住的请求会拖住整个循环。

5. Agent 核心循环

把前面所有模块串起来,加上生产环境需要的终止条件:

// src/agent/loop.ts
import type { Message, AgentEvent, AppEnv } from '../types'
import { callLLM } from './llm'
import { executeTool } from './tools/registry'
import type { ToolDefinition } from './tools/types'
 
export type StopReason = 'completed' | 'max_iterations' | 'error' | 'timeout'
 
export type AgentResult = {
  finalText: string
  messages: Message[]
  stopReason: StopReason
  iterations: number
}
 
const TOTAL_TIMEOUT_MS = 60_000
 
export async function runAgentLoop(
  messages: Message[],
  tools: ToolDefinition[],
  env: AppEnv['Bindings'],
  onEvent?: (event: AgentEvent) => void,
): Promise<AgentResult> {
  const maxIterations = Number(env.MAX_ITERATIONS) || 10
  const startTime = Date.now()
 
  for (let i = 0; i < maxIterations; i++) {
    // 检查总超时
    if (Date.now() - startTime > TOTAL_TIMEOUT_MS) {
      onEvent?.({ type: 'error', message: '执行超时' })
      return { finalText: '处理时间过长,请稍后重试。', messages, stopReason: 'timeout', iterations: i }
    }
 
    let llmResponse
    try {
      llmResponse = await callLLM(messages, tools, env.OPENAI_API_KEY)
    } catch (error) {
      const msg = error instanceof Error ? error.message : 'LLM 调用失败'
      onEvent?.({ type: 'error', message: msg })
      return { finalText: msg, messages, stopReason: 'error', iterations: i }
    }
 
    // 没有工具调用 → LLM 已给出最终回复
    if (llmResponse.toolCalls.length === 0) {
      messages.push({ role: 'assistant', content: llmResponse.text })
      onEvent?.({ type: 'done', finalText: llmResponse.text })
      return { finalText: llmResponse.text, messages, stopReason: 'completed', iterations: i + 1 }
    }
 
    messages.push({ role: 'assistant', content: llmResponse.text, toolCalls: llmResponse.toolCalls })
    onEvent?.({ type: 'thinking', content: llmResponse.text })
 
    for (const toolCall of llmResponse.toolCalls) {
      onEvent?.({ type: 'tool_call_start', toolName: toolCall.name })
      const result = await executeTool(toolCall, env)
      onEvent?.({ type: 'tool_call_result', toolName: toolCall.name, result })
      messages.push({ role: 'tool', toolCallId: toolCall.id, content: JSON.stringify(result) })
    }
  }
 
  // 达到最大轮次
  onEvent?.({ type: 'error', message: '达到最大执行轮次' })
  return { finalText: '任务未能完成。', messages, stopReason: 'max_iterations', iterations: maxIterations }
}

四个终止条件:

  1. completed — LLM 没有调用工具,已生成最终回复
  2. max_iterations — 循环达到上限,防止工具调用死循环
  3. error — LLM 调用失败
  4. timeout — 总执行时间超限,防止单个任务占用过多资源

onEvent 回调是流式响应的基础,每次循环中的关键事件都通过它推送出去。

6. 工具注册机制

前面 06 篇讲过工具执行器的设计。这里加上动态注册和权限过滤:

// src/agent/tools/types.ts
export type ToolDefinition = {
  name: string
  description: string
  parameters: Record<string, unknown>
  requiredRole?: string
  execute: (args: Record<string, unknown>, env: Record<string, string>) => Promise<unknown>
}
// src/agent/tools/registry.ts
import type { ToolCall, ToolDefinition } from './types'
 
const registry = new Map<string, ToolDefinition>()
 
export function registerTool(tool: ToolDefinition): void {
  if (registry.has(tool.name)) throw new Error(`工具已注册:${tool.name}`)
  registry.set(tool.name, tool)
}
 
// 按角色过滤:用户没权限的工具不会出现在发给 LLM 的列表里
export function getTools(userRole?: string): ToolDefinition[] {
  const all = Array.from(registry.values())
  if (!userRole) return all
  return all.filter((t) => !t.requiredRole || userRole === 'admin' || userRole === t.requiredRole)
}
 
export async function executeTool(toolCall: ToolCall, env: Record<string, string>): Promise<unknown> {
  const tool = registry.get(toolCall.name)
  if (!tool) return { error: `未知工具:${toolCall.name}` }
  try {
    return await tool.execute(toolCall.arguments, env)
  } catch (error) {
    return { error: `工具执行失败:${error instanceof Error ? error.message : '未知错误'}` }
  }
}

新增工具只需要写一个模块并调用 registerTool,不需要改执行器或循环。例如:

// src/agent/tools/weather.ts
import { registerTool } from './registry'
 
registerTool({
  name: 'get_weather',
  description: '查询指定城市的当前天气信息',
  parameters: {
    type: 'object',
    properties: { city: { type: 'string', description: '城市名称' } },
    required: ['city'],
  },
  async execute(args) {
    const res = await fetch(`https://api.weather.com/v1/${args.city}`, { signal: AbortSignal.timeout(5000) })
    if (!res.ok) return { error: `天气查询失败:${res.status}` }
    return res.json()
  },
})

7. 流式响应

Agent 调 3-4 个工具时可能耗时 15-20 秒,前端同步等待期间什么都看不到。用 SSE 把每一步状态实时推送出去:

// src/routes/agent.ts(流式接口部分)
import { Hono } from 'hono'
import { streamSSE } from 'hono/streaming'
import type { AppEnv, Message, AgentEvent } from '../types'
import { runAgentLoop } from '../agent/loop'
import { getTools } from '../agent/tools/registry'
import { getSession, createSession, appendMessages } from '../agent/session'
 
const agentApp = new Hono<AppEnv>()
 
// 同步接口
agentApp.post('/chat', async (c) => {
  const { message, sessionId } = await c.req.json<{ message: string; sessionId?: string }>()
  if (!message) return c.json({ error: 'message 不能为空' }, 400)
 
  const user = c.get('user')
  let session = sessionId ? await getSession(c.env.KV, sessionId) : null
  if (!session) session = await createSession(c.env.KV, user.id)
 
  await appendMessages(c.env.KV, session.id, [{ role: 'user', content: message }])
 
  const messages = [...session.messages]
  const result = await runAgentLoop(messages, getTools(user.role), c.env)
 
  // 保存 Agent 产生的新消息
  const newMessages = messages.slice(session.messages.length)
  await appendMessages(c.env.KV, session.id, newMessages)
 
  return c.json({ reply: result.finalText, sessionId: session.id, stopReason: result.stopReason })
})
 
// 流式接口:实时推送 Agent 执行过程
agentApp.post('/chat/stream', async (c) => {
  const { message, sessionId } = await c.req.json<{ message: string; sessionId?: string }>()
  if (!message) return c.json({ error: 'message 不能为空' }, 400)
 
  const user = c.get('user')
  let session = sessionId ? await getSession(c.env.KV, sessionId) : null
  if (!session) session = await createSession(c.env.KV, user.id)
 
  await appendMessages(c.env.KV, session.id, [{ role: 'user', content: message }])
 
  const messages = [...session.messages]
  return streamSSE(c, async (stream) => {
    await stream.writeSSE({ event: 'session', data: JSON.stringify({ sessionId: session!.id }) })
 
    const onEvent = async (event: AgentEvent) => {
      await stream.writeSSE({ event: event.type, data: JSON.stringify(event) })
    }
 
    const result = await runAgentLoop(messages, getTools(user.role), c.env, onEvent)
 
    const newMessages = messages.slice(session!.messages.length)
    await appendMessages(c.env.KV, session!.id, newMessages)
  })
})
 
export default agentApp

前端收到的 SSE 事件流大致是这样的:

event: session
data: {"sessionId":"abc-123"}
 
event: thinking
data: {"type":"thinking","content":"需要先查询天气信息"}
 
event: tool_call_start
data: {"type":"tool_call_start","toolName":"get_weather"}
 
event: tool_call_result
data: {"type":"tool_call_result","toolName":"get_weather","result":{"temperature":28}}
 
event: done
data: {"type":"done","finalText":"北京今天28度,晴天"}

前端根据 event 类型更新 UI:thinking 时展示思考动画,tool_call_start 时展示工具调用状态,done 时展示最终回复。

8. 入口文件与配置

// src/index.ts
import { Hono } from 'hono'
import { logger } from 'hono/logger'
import { cors } from 'hono/cors'
import type { AppEnv } from './types'
import { authMiddleware } from './middleware/auth'
import agentApp from './routes/agent'
 
// 导入工具模块,触发 registerTool
import './agent/tools/weather'
 
const app = new Hono<AppEnv>()
app.use('*', logger())
app.use('*', cors())
app.use('/api/*', authMiddleware)
app.route('/api/agent', agentApp)
app.get('/health', (c) => c.json({ status: 'ok' }))
 
export default app
// wrangler.jsonc
{
  "name": "agent-api",
  "main": "src/index.ts",
  "compatibility_date": "2024-12-01",
  "kv_namespaces": [{ "binding": "KV", "id": "xxxx" }],
  "d1_databases": [{ "binding": "DB", "database_name": "agent-db", "database_id": "xxxx" }],
  "vars": { "MAX_ITERATIONS": "10" }
}

OPENAI_API_KEY 通过 wrangler secret put OPENAI_API_KEY 设置,不写在配置文件里。

9. 错误处理汇总

一个请求从进入到返回,各层可能遇到的错误和处理策略:

接口层 — 请求体缺字段返回 400,鉴权失败返回 401,会话不存在返回 404,无权访问返回 403。这些都是同步返回,不需要走 Agent Loop。

LLM 调用层 — API 限流(429)直接返回错误,不重试避免雪崩;网络超时 30 秒后终止循环;模型返回格式异常时捕获 JSON 解析错误。

工具执行层 — 参数校验失败和执行异常都返回给 LLM,让模型自行修正或放弃;单个工具 5 秒超时,返回超时错误。

循环层 — 达到最大迭代次数或总超时(60 秒)时返回提示文本,stopReason 标记终止原因。

核心原则:工具执行失败时把错误信息返回给 LLM,让模型自行决定重试、换参数还是放弃。LLM 在大多数情况下能根据错误信息做出合理判断。

总结

回顾一下这篇的要点:

  • 完整的 Agent API 分三层:接口层处理 HTTP 和鉴权,编排层驱动 LLM 与工具交互,工具层管理定义和执行
  • 会话管理用 KV 存储,支持多轮对话、TTL 自动过期、活跃续期
  • Agent 核心循环有四个终止条件:LLM 给出最终回复、达到最大迭代次数、LLM 调用失败、总执行超时
  • 工具注册采用 Map + 动态注册,新增工具只需写一个模块调用 registerTool,不改执行器或循环
  • 流式响应通过 SSE 推送每一步状态,前端实时展示思考过程和工具调用进度
  • 错误处理按层分类,工具层失败把错误返回给 LLM 让它自我修正

这篇把前面十几篇的模块组装成了一个可运行的 Agent API 服务。可以在此基础上继续扩展——接入更多 LLM 提供商、增加工具并发执行、接入向量数据库做长期记忆检索。