要解决的问题
生产级客服接口不能只产出一段看似合理的回复。输入必须校验,步骤之间要有可预测的数据结构,失败要映射成明确的 HTTP 行为,而且每次请求都不该重新推导同一套拓扑。
通过 HTTP 接收客服工单,执行固定的「分类 → 起草 → QA」流水线,再返回经 schema 校验的 JSON。
适合希望把可重复 AI 工作流嵌进后端 API 的开发与客服运营团队
生产级客服接口不能只产出一段看似合理的回复。输入必须校验,步骤之间要有可预测的数据结构,失败要映射成明确的 HTTP 行为,而且每次请求都不该重新推导同一套拓扑。
JSON body 包含工单标题与正文;Express 路由会在任何模型调用前拒绝格式错误或字段缺失的输入。
每个专职 Agent 只处理它需要的证据;互不依赖的工作同时进行,需要上游结果的工作则按顺序等待。
第一项任务返回经过 schema 校验的类别与紧急程度。
判断问题类别并标记紧急程度。
起草 Agent 通过显式任务依赖拿到原始工单和分类结果。
撰写有同理心、面向客户的回复。
最后一步结合原始工单与分类结果检查回复。
检查语气、同理心与事实一致性。
接口组装三个结构化结果,并把输入错误、流水线失败和超时映射成明确状态码。
上面是面向业务的解释;下面的命令、运行前提、API 和源码继续与真实仓库同步。
OpenMultiAgentAgentConfigSupportedProvider在仓库的克隆里运行这个文件:
cd packages/core/examples/integrations/express-customer-supportnpm installexport DEEPSEEK_API_KEY=sk-... # default — all three agents use DeepSeeknpm start
完整示例,从固定的 Framework commit 同步。
/*** Express Customer Support** POST /tickets { subject, body } → runs a three-agent pipeline* (classifier → drafter → QA reviewer) and returns structured JSON.** Run:* npm install && npm start** Prerequisites:* API key for the chosen provider(s) — defaults to DEEPSEEK_API_KEY.* CLASSIFIER_PROVIDER / DRAFTER_PROVIDER / QA_PROVIDER (optional, default 'deepseek')* CLASSIFIER_MODEL / DRAFTER_MODEL / QA_MODEL (optional, see defaults below)* PORT (optional, default 3000)*/import { fileURLToPath } from 'node:url'import express from 'express'import { z } from 'zod'import { OpenMultiAgent } from '@open-multi-agent/core'import type { AgentConfig, SupportedProvider } from '@open-multi-agent/core'// ---------------------------------------------------------------------------// Schemas// ---------------------------------------------------------------------------const ClassifierOutput = z.object({category: z.enum(['billing', 'technical', 'shipping', 'returns', 'general']),urgency: z.enum(['low', 'medium', 'high', 'critical']),})const DrafterOutput = z.object({draft_reply: z.string().describe('Polished customer-facing reply'),})const QAOutput = z.object({qa_notes: z.string().describe('Tone and accuracy feedback for the draft'),})export const SupportTicketResponse = z.object({category: ClassifierOutput.shape.category,urgency: ClassifierOutput.shape.urgency,draft_reply: DrafterOutput.shape.draft_reply,qa_notes: QAOutput.shape.qa_notes,})export type SupportTicketResponse = z.infer<typeof SupportTicketResponse>// ---------------------------------------------------------------------------// Provider / model configuration// ---------------------------------------------------------------------------// Each agent's provider and model are independently overridable via env vars,// so free-tier users can mix providers per tier. Validate at startup to fail// fast instead of erroring deep inside an HTTP request's LLM call.const PROVIDER_ENV_KEYS: Record<string, string> = {anthropic: 'ANTHROPIC_API_KEY',openai: 'OPENAI_API_KEY',gemini: 'GEMINI_API_KEY',grok: 'XAI_API_KEY',copilot: 'GITHUB_TOKEN',deepseek: 'DEEPSEEK_API_KEY',minimax: 'MINIMAX_API_KEY','azure-openai': 'AZURE_OPENAI_API_KEY',}function pickAgent(envPrefix: string, defaultProvider: SupportedProvider, defaultModel: string) {const provider = (process.env[`${envPrefix}_PROVIDER`] ?? defaultProvider) as SupportedProviderconst model = process.env[`${envPrefix}_MODEL`] ?? defaultModelconst envKey = PROVIDER_ENV_KEYS[provider]if (envKey && !process.env[envKey]?.trim()) {console.error(`Missing ${envKey}: required for ${envPrefix}_PROVIDER="${provider}".`)process.exit(1)}return { provider, model }}const classifierCfg = pickAgent('CLASSIFIER', 'deepseek', 'deepseek-v4-flash')const drafterCfg = pickAgent('DRAFTER', 'deepseek', 'deepseek-v4-pro')const qaCfg = pickAgent('QA', 'deepseek', 'deepseek-v4-pro')// ---------------------------------------------------------------------------// Agents// ---------------------------------------------------------------------------const classifier: AgentConfig = {name: 'classifier',provider: classifierCfg.provider,model: classifierCfg.model,systemPrompt: 'You are a customer support classifier. Given a ticket subject and body, classify it into exactly one category (billing, technical, shipping, returns, general) and one urgency level (low, medium, high, critical). Respond ONLY with valid JSON: {"category":"<one of the above>","urgency":"<one of the above>"}.',outputSchema: ClassifierOutput,maxTurns: 3,temperature: 0.1,}const drafter: AgentConfig = {name: 'drafter',provider: drafterCfg.provider,model: drafterCfg.model,systemPrompt: 'You are a customer support specialist. Your task prompt will contain the original support ticket and a "Context from prerequisite tasks" section with the classifier\'s JSON output (category and urgency). Use both to write a clear, empathetic customer-facing reply. Respond ONLY with valid JSON.',outputSchema: DrafterOutput,maxTurns: 4,temperature: 0.4,}const qaReviewer: AgentConfig = {name: 'qa-reviewer',provider: qaCfg.provider,model: qaCfg.model,systemPrompt: 'You are a QA reviewer for customer support. Your task prompt will contain a "Context from prerequisite tasks" section with the classifier\'s category/urgency and the drafter\'s reply. Review the draft reply for tone, empathy, and accuracy against the original ticket. Provide concise QA notes. Respond ONLY with valid JSON.',outputSchema: QAOutput,maxTurns: 3,temperature: 0.2,}// ---------------------------------------------------------------------------// HTTP server// ---------------------------------------------------------------------------export function createApp() {const app = express()app.use(express.json())app.use((err: unknown, _req: express.Request, res: express.Response, next: express.NextFunction) => {if (err instanceof SyntaxError && 'status' in err && (err as { status: number }).status === 400) {res.status(400).json({ error: 'Invalid JSON body' })return}next(err)})const orchestrator = new OpenMultiAgent({onProgress: (event) => {const agent = 'agent' in event ? event.agent : ''const extra = event.type === 'error' && 'data' in event ? ` — ${JSON.stringify(event.data)}` : ''console.log(`[${event.type}] ${agent}${extra}`)},})const team = orchestrator.createTeam('support-team', {name: 'support-team',agents: [classifier, drafter, qaReviewer],})app.post('/tickets', async (req, res) => {const { subject, body } = req.body ?? {}if (typeof subject !== 'string' || typeof body !== 'string' || !subject || !body) {res.status(400).json({ error: 'Request body must include non-empty string fields: subject, body' })return}// `runTasks` resolves normally on abort (skipRemaining → success: false), so// race it against a sentinel to distinguish a 60s timeout from a generic// pipeline failure. Keep the AbortSignal wired through so in-flight LLM// fetches still get cancelled when the timer fires.const TIMEOUT_MS = 60_000const abortController = new AbortController()const timeoutSentinel = Symbol('timeout')const timeoutHandle = setTimeout(() => abortController.abort(), TIMEOUT_MS)const timeoutPromise = new Promise<typeof timeoutSentinel>((resolve) => {abortController.signal.addEventListener('abort', () => resolve(timeoutSentinel), { once: true })})try {const ticketContext = `Subject: "${subject}"\nBody: "${body}"`const raced = await Promise.race([orchestrator.runTasks(team, [{title: 'Classify ticket',description: `Classify the following support ticket.\n\n${ticketContext}`,assignee: 'classifier',},{title: 'Draft reply',description: `Write a customer-facing reply for the following support ticket.\n\n${ticketContext}`,assignee: 'drafter',dependsOn: ['Classify ticket'],},{title: 'QA review',description: `Review the draft reply for tone, empathy, and accuracy.\n\n${ticketContext}`,assignee: 'qa-reviewer',dependsOn: ['Classify ticket', 'Draft reply'],},], { abortSignal: abortController.signal }),timeoutPromise,])if (raced === timeoutSentinel) {res.status(504).json({ error: 'Pipeline timed out after 60 seconds' })return}const result = racedif (!result.success) {res.status(502).json({ error: 'Pipeline did not complete successfully' })return}const classifierResult = result.agentResults.get('classifier')const drafterResult = result.agentResults.get('drafter')const qaResult = result.agentResults.get('qa-reviewer')const classOut = classifierResult?.structured as z.infer<typeof ClassifierOutput> | undefinedconst draftOut = drafterResult?.structured as z.infer<typeof DrafterOutput> | undefinedconst qaOut = qaResult?.structured as z.infer<typeof QAOutput> | undefinedif (!classOut || !draftOut || !qaOut) {res.status(502).json({ error: 'One or more agents failed to produce structured output' })return}const response: SupportTicketResponse = {category: classOut.category,urgency: classOut.urgency,draft_reply: draftOut.draft_reply,qa_notes: qaOut.qa_notes,}res.json(response)} catch (err: unknown) {const message = err instanceof Error ? err.message : String(err)res.status(502).json({ error: `LLM pipeline failed: ${message}` })} finally {clearTimeout(timeoutHandle)}})return app}// Only start the server when this file is executed directly (e.g. `npm start`).// Importers like the smoke test get the factory + schema with no side effects,// so they can bind their own ephemeral port without colliding on PORT 3000.if (process.argv[1] === fileURLToPath(import.meta.url)) {const PORT = parseInt(process.env.PORT ?? '3000', 10)createApp().listen(PORT, () => console.log(`Support API listening on http://localhost:${PORT}`))}
open-multi-agent 采用 MIT 许可、可自行免费运行。当你需要在期限内交付、集成,或获得支持时,元定义科技(YuanASI)提供商业交付与支持。