Hono 框架入门到实战:用 Node.js 写一个支持工具调用的流式对话 Agent

Hono 框架入门到实战:用 Node.js 写一个支持工具调用的流式对话 Agent

结论先行(适合前端/Node 全栈):

  • 如果你想要一个"像 Express 一样简单、但更现代、还能跑在多种 runtime(Node/Edge/Bun)"的框架,Hono 值得优先试。
  • Hono 的心智负担很低:路由 + 中间件 + Context,配合 TypeScript 能写得很顺。
  • 这篇文章最后会用 Hono 做一个 SSE 流式输出 的对话接口,并加入 OpenAI 风格工具调用(function calling),把它变成一个"能干活"的小 Agent。

1)Hono 是什么?适合谁?

Hono 是一个轻量、现代、跨运行时的 Web 框架。

你可以把它理解成:

  • API 风格接近 Express/Koa(路由 + 中间件)
  • 但更偏"现代 runtime":比如 Cloudflare Workers / Edge 场景
  • 在 Node.js 上也同样可用(本文以 Node.js 为主)

适合场景

  • BFF / API Gateway 的轻量服务
  • 前端团队自建小型后端(认证、聚合、转发)
  • Edge-friendly 的接口(同一套代码跑多 runtime)

不太适合(至少需要额外工程化)

  • 超大型、强约束、强分层的企业后端(你可能更想要 NestJS 一类)

2)安装与最小可运行示例(Node.js)

2.1 初始化项目

bash 复制代码
mkdir hono-agent-demo && cd hono-agent-demo
pnpm init
pnpm add hono
pnpm add -D tsx typescript @types/node

你也可以用 npm/yarn。这里用 pnpm 只是因为全栈团队常见。

2.2 最小服务

新建 src/index.ts

ts 复制代码
import { Hono } from 'hono'
import { serve } from '@hono/node-server'

const app = new Hono()

app.get('/', (c) => c.text('Hello Hono'))

serve({ fetch: app.fetch, port: 3000 })
console.log('Server running at http://localhost:3000')

运行:

bash 复制代码
pnpm tsx src/index.ts

3)常见模块与用法(全栈开发者最常用的那部分)

3.1 路由参数、Query、JSON

ts 复制代码
app.get('/users/:id', (c) => {
  const id = c.req.param('id')
  const q = c.req.query('q')
  return c.json({ id, q })
})

app.post('/echo', async (c) => {
  const body = await c.req.json()
  return c.json({ ok: true, body })
})

3.2 中间件:日志、鉴权(最常见)

Hono 的中间件跟 Koa 类似:

ts 复制代码
app.use('*', async (c, next) => {
  const start = Date.now()
  await next()
  const cost = Date.now() - start
  console.log(c.req.method, c.req.path, c.res.status, cost + 'ms')
})

app.use('/api/*', async (c, next) => {
  const token = c.req.header('authorization')
  if (!token) return c.json({ error: 'unauthorized' }, 401)
  await next()
})

3.3 输入校验(建议必做)

全栈项目里"接口输入校验"很关键,否则 AI/前端/第三方一来就容易把服务打爆。

常见做法:用 zod(示例):

bash 复制代码
pnpm add zod
ts 复制代码
import { z } from 'zod'

const ChatReq = z.object({
  message: z.string().min(1),
})

app.post('/api/chat', async (c) => {
  const json = await c.req.json().catch(() => null)
  const parsed = ChatReq.safeParse(json)
  if (!parsed.success) return c.json({ error: parsed.error.issues }, 400)
  return c.json({ ok: true })
})

3.4 CORS(前端必备)

bash 复制代码
pnpm add hono cors

Hono 的 CORS 中间件在 hono/cors

ts 复制代码
import { cors } from 'hono/cors'
app.use('/api/*', cors({ origin: '*'}))

4)实战:用 Hono 写一个"支持工具调用的流式对话 Agent"

目标:

  • 前端调用 /api/agent/stream
  • 服务端用 SSE 持续输出 token
  • 支持 OpenAI 风格工具调用:模型决定要不要调用工具,我们执行工具并把结果回填给模型,再继续生成

说明:下面以"OpenAI 兼容接口"为例(也适用于一些兼容 OpenAI API 的服务)。

4.1 约定:SSE 输出格式

前端用 EventSourcefetch + ReadableStream 都可以。

我们约定服务端输出形如:

vbnet 复制代码
event: message
data: {"type":"delta","text":"..."}

event: message
data: {"type":"tool","name":"get_time","result":"..."}

4.2 准备一个最小"工具集"

我们定义两个工具:

  • get_time:返回当前时间
  • calc:做一个极简四则运算(演示用,生产不要 eval)
ts 复制代码
type ToolCall = { name: string; arguments: any }

async function runTool(call: ToolCall) {
  if (call.name === 'get_time') {
    return new Date().toISOString()
  }
  if (call.name === 'calc') {
    const { a, b, op } = call.arguments || {}
    if (typeof a !== 'number' || typeof b !== 'number') throw new Error('bad args')
    if (!['+','-','*','/'].includes(op)) throw new Error('bad op')
    const res = op === '+' ? a+b : op === '-' ? a-b : op === '*' ? a*b : a/b
    return String(res)
  }
  throw new Error('unknown tool')
}

4.3 OpenAI 风格工具定义(function calling)

ts 复制代码
const tools = [
  {
    type: 'function',
    function: {
      name: 'get_time',
      description: 'Get current time in ISO string',
      parameters: {
        type: 'object',
        properties: {},
        required: [],
      },
    },
  },
  {
    type: 'function',
    function: {
      name: 'calc',
      description: 'Simple calculator',
      parameters: {
        type: 'object',
        properties: {
          a: { type: 'number' },
          b: { type: 'number' },
          op: { type: 'string', enum: ['+','-','*','/'] },
        },
        required: ['a','b','op'],
      },
    },
  },
] as const

4.4 关键:流式对话 + 工具调用循环

你可以用官方 SDK 或自己 fetch。这里用 fetch 写清楚底层。

bash 复制代码
pnpm add undici
ts 复制代码
import { request } from 'undici'

type Msg = { role: 'system'|'user'|'assistant'|'tool'; content?: string; name?: string; tool_call_id?: string }

async function openaiStreamWithTools({
  apiKey,
  baseUrl,
  model,
  messages,
  onDelta,
  onToolResult,
}: {
  apiKey: string
  baseUrl: string
  model: string
  messages: Msg[]
  onDelta: (text: string) => void
  onToolResult: (name: string, result: string) => void
}) {
  // 这里用一个循环:模型可能多次要求调用工具
  let loopMessages = [...messages]

  for (let round = 0; round < 5; round++) {
    const { body } = await request(`${baseUrl}/v1/chat/completions`, {
      method: 'POST',
      headers: {
        'content-type': 'application/json',
        authorization: `Bearer ${apiKey}`,
      },
      body: JSON.stringify({
        model,
        stream: true,
        messages: loopMessages,
        tools,
        tool_choice: 'auto',
      }),
    })

    // 解析 SSE 数据流(data: {...})
    const decoder = new TextDecoder()
    let buffer = ''

    // 收集工具调用(OpenAI 在 stream delta 里会逐步给 tool_calls)
    const toolCalls: any[] = []

    for await (const chunk of body) {
      buffer += decoder.decode(chunk)

      // 简化:按行处理
      const lines = buffer.split('
')
      buffer = lines.pop() || ''

      for (const line of lines) {
        const t = line.trim()
        if (!t.startsWith('data:')) continue
        const data = t.slice(5).trim()
        if (data === '[DONE]') break

        const json = JSON.parse(data)
        const delta = json.choices?.[0]?.delta

        // 普通文本增量
        if (delta?.content) onDelta(delta.content)

        // 工具调用增量
        if (delta?.tool_calls) {
          for (const tc of delta.tool_calls) {
            const idx = tc.index
            toolCalls[idx] ??= { id: tc.id, function: { name: '', arguments: '' } }
            if (tc.id) toolCalls[idx].id = tc.id
            if (tc.function?.name) toolCalls[idx].function.name += tc.function.name
            if (tc.function?.arguments) toolCalls[idx].function.arguments += tc.function.arguments
          }
        }
      }
    }

    // 如果没有工具调用,说明这轮结束
    const finalized = toolCalls.filter(Boolean)
    if (finalized.length === 0) return

    // 执行工具并回填
    for (const tc of finalized) {
      const name = tc.function.name
      const args = JSON.parse(tc.function.arguments || '{}')
      const result = await runTool({ name, arguments: args })
      onToolResult(name, result)

      loopMessages.push({ role: 'assistant', content: '', })
      loopMessages.push({ role: 'tool', tool_call_id: tc.id, content: result })
    }
  }
}

注意:上面为了讲清楚流程做了简化,生产环境建议:

  • 更严谨的 SSE 解析
  • 工具调用轮次限制、超时控制
  • 记录 tool_call_id 与请求 trace

4.5 Hono 路由:SSE 输出

ts 复制代码
import { Hono } from 'hono'
import { serve } from '@hono/node-server'
import { z } from 'zod'

const app = new Hono()

const ReqSchema = z.object({
  message: z.string().min(1),
})

app.post('/api/agent/stream', async (c) => {
  const json = await c.req.json().catch(() => null)
  const parsed = ReqSchema.safeParse(json)
  if (!parsed.success) return c.json({ error: parsed.error.issues }, 400)

  const { message } = parsed.data

  const apiKey = process.env.OPENAI_API_KEY || ''
  const baseUrl = process.env.OPENAI_BASE_URL || 'https://api.openai.com'
  const model = process.env.OPENAI_MODEL || 'gpt-4o-mini'

  if (!apiKey) return c.json({ error: 'missing OPENAI_API_KEY' }, 500)

  const stream = new ReadableStream({
    start(controller) {
      const enc = new TextEncoder()
      const send = (obj: any) => {
        controller.enqueue(enc.encode(`event: message
data: ${JSON.stringify(obj)}

`))
      }

      ;(async () => {
        try {
          const messages = [
            { role: 'system', content: 'You are a helpful assistant. Use tools when needed.' },
            { role: 'user', content: message },
          ] as any

          await openaiStreamWithTools({
            apiKey,
            baseUrl,
            model,
            messages,
            onDelta: (text) => send({ type: 'delta', text }),
            onToolResult: (name, result) => send({ type: 'tool', name, result }),
          })

          send({ type: 'done' })
        } catch (e: any) {
          send({ type: 'error', message: e?.message || String(e) })
        } finally {
          controller.close()
        }
      })()
    },
  })

  return new Response(stream, {
    headers: {
      'content-type': 'text/event-stream; charset=utf-8',
      'cache-control': 'no-cache',
      connection: 'keep-alive',
    },
  })
})

serve({ fetch: app.fetch, port: 3000 })

5)本地测试

启动:

bash 复制代码
OPENAI_API_KEY=xxx pnpm tsx src/index.ts

请求(你可以用 curl 看 SSE):

bash 复制代码
curl -N -X POST http://localhost:3000/api/agent/stream   -H 'content-type: application/json'   -d '{"message":"现在几点了?再帮我算 12*7"}'

你应该能看到:

  • 先流式输出一段对话
  • 中间穿插一条 tool 消息(get_time / calc)
  • 最后 done

6)上线与安全注意

  • 工具调用一定要做白名单 + 参数校验(不要把任意命令暴露给模型)
  • 限制轮次、超时、并发,避免被滥用
  • 记录 tool_call_id、请求 trace,方便排障与复盘

7)结语

Hono 的优势在于:轻量 + 现代 + 跨运行时,非常适合全栈团队快速搭一个 BFF 或小型服务。

当你把"工具调用 + 流式输出"接进去,它就不只是一个 API 服务,而可以变成一个真正能完成任务的对话 Agent。

相关推荐
用户8356290780511 小时前
Python 自动拆分 Word 文档教程:按分节符与分页符处理
后端·python
树獭叔叔2 小时前
Claude Code 工具系统深度剖析:从静态注册到动态发现
后端·aigc·openai
树獭叔叔2 小时前
Claude Code 的上下文管理:多层渐进式压缩架构深度解析
后端·aigc·openai
计算机学姐2 小时前
基于SpringBoot的高校竞赛管理系统
java·spring boot·后端·spring·信息可视化·tomcat·mybatis
nghxni2 小时前
LightESB PlatformHttp v1.0.0:DS 数据转换实践
后端
卷毛的小庄2 小时前
被 AI 惯坏后踩的坑:Spring 代理对象 + 反射 = NPE
后端
huanmieyaoseng10032 小时前
SpringBoot使用Redis缓存
java·spring boot·后端
无心水3 小时前
2、5分钟上手|PyPDF2 快速提取PDF文本
java·linux·分布式·后端·python·架构·pdf
他日若遂凌云志3 小时前
一文搞懂多线程:解锁并发编程
后端