Agent 对话 API
通过一个服务端小代理,把 Agent 接到你自己的聊天界面上——文档站的 Ask AI 面板,或者客服窗口。
Agent 对话 API 可以把 Agent 接到任何基于 AI SDK useChat 的聊天界面上(UI message stream 协议 v1)。每一轮对话是一次 HTTP POST,回复以 server-sent events 流返回。API 密钥放在你服务器上运行的一个小代理里;浏览器既拿不到密钥,也拿不到 token。
常见用法是文档站上的 “Ask AI” 面板或客服窗口。本站的 Ask AI 面板就是这样实现的。
一共三步:
- 创建一个仅限 Agent 对话的 API 密钥。
- 每个会话开始时,代理用密钥换取一个短期 token。
- 每一轮对话,代理带着这个 token 转发
useChat的请求,再把回复流式传回浏览器。
创建密钥
个人资料 → API 密钥 → 新建密钥:
- 权限——Agent 对话。
- Agent——指定 Agent,然后选中这个 Agent。
这种密钥只能调用一个接口,也就是下面的 token 签发接口,而且只对选中的 Agent 有效;其他任何请求都返回 403。和所有 API 密钥一样,它只显示一次,默认 90 天后过期。
签发 token
每个会话用密钥签发一个 token:
curl -X POST "https://api.agentconnect.md/v1/orgs/$ORG_ID/agents/$AGENT_ID/webchat/token" \
-H "Authorization: Bearer $AGENTCONNECT_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'请求体 {} 开启一个新会话;{ "conversationId": "<uuid>" } 继续已有会话。响应为:
{ "token": "…", "relayUrl": "…", "conversationId": "…", "expiresAt": "…" }token 有效期 5 分钟(expiresAt)。在此之前,这个会话的每一轮都复用它,过期后重新签发。继续一个不存在的会话返回 404;409 表示 Agent 已迁移,这个会话需要重新开启。两种情况都开启新会话即可。
在 AgentConnect OSS 上,同一接口位于 <control-plane-url>/api/v1/orgs/…。
发送一轮对话
发送 POST {relayUrl}/ai-sdk/chat/{conversationId},带上 Authorization: Bearer <token>,请求体就是 useChat 的原样请求({ id, messages, trigger, … }):
curl -N -X POST "$RELAY_URL/ai-sdk/chat/$CONVERSATION_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"id":"chat-1","trigger":"submit-message","messages":[{"id":"m1","role":"user","parts":[{"type":"text","text":"How do I install the daemon?"}]}]}'响应为 text/event-stream,带有 x-vercel-ai-ui-message-stream: v1 头。各个 part 遵循 AI SDK 协议,流以 [DONE] 结束。
- relay 只读取最后一条用户消息的文本 part。Agent 的会话本身已保存历史,所以更早的消息、文件 part 和
data-*part 都会被忽略。 - 一轮对话可能持续几分钟,期间每 15 秒会收到一条 keepalive 注释。
- 请求体最大 4 MiB,一轮的文本最大 128 KiB,超出返回
413。 - 一个会话同一时间只运行一轮。上一轮还在运行时再次 POST,返回
409。
Agent 所在的 Daemon 拒绝这一轮时,状态码会在任何流之前返回:
| 状态码 | 含义 |
|---|---|
409 | 这个会话还有一轮在运行。 |
503 | Agent 离线、已暂停或正在排空。 |
404 | 会话已不存在。 |
502 | 其他拒绝。 |
流中包含什么
| Part | 内容 |
|---|---|
text-* | Agent 的回复。 |
reasoning-* | Agent 的思考过程。 |
data-tool | 工具调用:只有标题和状态。 |
data-plan | Agent 的计划。 |
data-notice | 状态提示,例如 “Starting sandbox…”。 |
message-metadata | 会话标题。 |
只渲染文本的客户端只会显示回复。useChat 的 stop() 只中止 fetch:这一轮仍会跑完,输出写入控制台里这个会话的记录。中断的流目前还不能续传。
运行代理
代理位于 useChat 和 AgentConnect 之间,需要做到:
- 把每个访客、每个打开的标签页对应到一个会话 id,例如存在 HttpOnly cookie 里。不要信任客户端发来的 chat
id。 - 历史中只有一条用户消息的请求,视为新会话。
useChat在客户端清空历史时不会通知服务端。 - 原样转发响应体,以及
content-type和x-vercel-ai-ui-message-stream两个头。 - 按访客限流。AgentConnect 只按会话限制并发,不按访客限流。
一个最小的 Next.js 路由处理器 app/api/chat/route.ts,每个浏览器一个会话:
const API = 'https://api.agentconnect.md/v1'
const { AGENTCONNECT_API_KEY, AGENTCONNECT_ORG_ID, AGENTCONNECT_AGENT_ID } = process.env
type Minted = { token: string; relayUrl: string; conversationId: string; expiresAt: string }
const tokens = new Map<string, Minted>()
async function mint(conversationId?: string): Promise<Minted> {
const cached = conversationId ? tokens.get(conversationId) : undefined
if (cached && Date.parse(cached.expiresAt) - 30_000 > Date.now()) return cached
const res = await fetch(`${API}/orgs/${AGENTCONNECT_ORG_ID}/agents/${AGENTCONNECT_AGENT_ID}/webchat/token`, {
method: 'POST',
headers: { authorization: `Bearer ${AGENTCONNECT_API_KEY}`, 'content-type': 'application/json' },
body: JSON.stringify(conversationId ? { conversationId } : {})
})
if (!res.ok) throw Object.assign(new Error(`token mint failed: ${res.status}`), { status: res.status })
const minted = (await res.json()) as Minted
tokens.set(minted.conversationId, minted)
return minted
}
export async function POST(req: Request) {
const body = await req.json()
const userTurns = body.messages.filter((m: { role: string }) => m.role === 'user').length
const bound = req.headers.get('cookie')?.match(/(?:^|;\s*)chat_conversation=([0-9a-f-]{36})/)?.[1]
// A lone first question starts over; an unknown (404) or moved (409) conversation does too. Any other failure surfaces.
const reopen = (err: { status?: number }) => (err.status === 404 || err.status === 409 ? mint() : Promise.reject(err))
const { token, relayUrl, conversationId } =
userTurns > 1 && bound ? await mint(bound).catch(reopen) : await mint()
const upstream = await fetch(`${relayUrl}/ai-sdk/chat/${conversationId}`, {
method: 'POST',
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body: JSON.stringify(body),
signal: req.signal
})
const headers = new Headers({ 'cache-control': 'no-store' })
for (const name of ['content-type', 'x-vercel-ai-ui-message-stream']) {
const value = upstream.headers.get(name)
if (value) headers.set(name, value)
}
headers.append('set-cookie', `chat_conversation=${conversationId}; Path=/; HttpOnly; Secure; SameSite=Lax`)
return new Response(upstream.body, { status: upstream.status, headers })
}客户端把 useChat 的 transport 指向这个路由(new DefaultChatTransport({ api: '/api/chat' }))。
本站自己的代理在 agentconnect-md/docs 仓库中,另外做了按标签页区分的 cookie、有上限的 token 缓存和错误映射:见 lib/ask-ai.ts 和 app/api/chat/route.ts。面板用的是 Fumadocs 的 AI 搜索组件。
会话与安全
这样开启的会话是密钥所有者的普通 webchat 会话。它在控制台中对该用户私密,来源显示为这个密钥。用量和其他对话一样计入这个 Agent;见账单与用量。
故障排查
403“limited to agent:chat”——Agent 对话密钥被发到了 token 签发接口之外的接口。检查 URL,以及代理发送的是不是你想用的密钥。- 签发时
404“agent not found”——密钥选中的 Agent 不包含这个 Agent。 - 发送一轮时
409——这个会话还有一轮在运行。等它结束,或开启新会话。 - 部署后立即出现
503——Agent 所在的 Daemon 正在重连。重试即可。