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 输出格式
前端用 EventSource 或 fetch + 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。