跳到正文
深圳 · 大湾区 · 地球

如何用 Next.js 与 MCP 构建零边际成本的企业级 AI 工作流

多数 B2B AI 工作流死在成本结构上,而不是模型质量上。这是我实际交付的架构:收窄的 MCP 工具、无状态编排、幂等写入,以及一套能在采购会上站得住的成本模型。

11 分钟阅读2,438
AI WorkflowsModel Context ProtocolNext.js ArchitectureSystems本文同时提供 English

对 B2B 工作流来说,“零边际成本”到底意味着什么?

零边际成本的工作流是这样一种东西:第一万次运行和第一次运行的成本形状完全相同——几美分的推理开销,几百毫秒的 CPU,不多一个人头。

这就是全部主张。不是说推理不要钱,而是成本曲线不再向上弯折。第一次运行和第一万次运行之间,差的是流量的一个常数倍,不是招聘带来的一次阶跃。

传统 B2B 软件按席位定价,因为过去价值确实随人数增长。当工作改由 agent 完成,这个前提当场失效。一个每月为 40 个人跑 9000 次的报价工作流,不是 40 个席位的问题;按席位收钱反而制造出一个奇怪的激励:跑一次和跑一万次,客户付的钱一样,于是厂商内部没有任何人有理由把它做便宜。按 token 计费把激励反转过来。你做的每一次优化都落在自己的毛利上,而且一个月内就能看见。

为企业客户构建这类工作流。能在采购环节活下来的模式很一致:把 LLM 放在真正需要判断的地方,让确定性的核心保持确定性,并让每次运行的成本能在一张表里读清楚。第三点最常被忽略,但它决定了这套东西能不能进合同。

一次 AI 工作流运行的真实成本是多少?

有用的单位不是 token,而是每次成功运行的成本——包含重试和失败尝试。这三个数字——单次成功成本、重试成本、失败尝试成本——必须放进同一张表。把所有失败尝试排除在外算出来的成本,会在第一次月度账单出来时失效。

拿一个中等规模的报价工作流举例:8000 token 稳定的 system prompt 加价目表,2000 token 易变的客户需求描述,1200 token 输出,大约四次 tool call。按以下示例单价——缓存输入每百万 token $0.25,新增输入每百万 $2.50,输出每百万 $10.00;换成你供应商当前的数字即可,结构不变——单次运行成本是:

  • 缓存输入:0.008M × $0.25 = $0.002
  • 新增输入:0.002M × $2.50 = $0.005
  • 输出:0.0012M × $10.00 = $0.012
  • 合计:单次约 $0.019,也就是 1.9 美分

每月 10000 次运行,推理成本 190 美元。同样体量如果走 29 美元一个席位、50 个席位的方案,是 1450 美元。这个毛利差距不是四舍五入的误差,而是“可以打包卖的工作流”和“必须小心计量才敢卖的工作流”之间的差别。前者可以按套餐卖;后者每接一个客户,都得把报价重算一遍。

有两个结构性细节比模型选型更重要:

稳定前缀在前,易变内容在后。 prompt caching 只有在被缓存的 prefix 逐字节一致时才划算。把 system prompt、工具定义、价目表按固定顺序放在最前面,客户需求描述追加到末尾。如果你在第一行插了一个时间戳或 request ID,你就为整次运行关掉了缓存,输入成本直接翻三倍。

按缓存 TTL 批量处理。 多数供应商的缓存只保留几分钟,不是几小时。被单个用户逐次触发的工作流等于在浪费它。一个每 60 秒排空一次的队列,可以用一次缓存写入服务几百次运行。

维度按席位收费的 SaaS按 token 计费的 agent 运行混合模式(平台费 + 计量运行)
计价单位具名的人类用户token,或一次运行席位底价加运行超量
第 10000 次运行时厂商的边际成本接近零,但毛利被席位数封顶很小、线性、在 COGS 里可见很小、线性,还被摊薄
用量增长由谁吸收厂商,而且不可见客户,而且透明双方分担,合同里写明
采购阻力一开始高,直到谈定上限
失败模式客户发现价值增长但席位没涨客户担心账单没有上限合同变复杂
最适用的场景人驱动的工具由 agent 完成实际工作的工作流需要一个底价的企业客户

混合模式那一行通常就是企业单子最后落地的地方。先卖一笔覆盖集成固定成本的平台费,再给一个带客户自设硬上限的计量运行额度。没有人在第一次会议上就签一份无上限的 token 合同。混合模式也不解决全部问题:上限一旦写进合同,你就得为自己的估算准确性负责。

编排逻辑应该放在业务流程的哪个位置?

在写 prompt 之前,先把每个工作流拆成三类步骤。

确定性的步骤——定价算术、税费、库存扣减、汇率换算、权限校验——在任何情况下都不该交给语言模型。算术不是 LLM 的能力,它是一次库函数调用。如果让模型去算总额,你就得为一个随机函数做正确性测试,而且测的正好是财务团队唯一会审计的那个输出。这跟模型能力无关,是概率系统的性质问题。

判断类的步骤——给进来的请求分类、把一段混乱的需求描述映射到 SKU 目录、为续约信挑合适的语气、判断某个请求是否需要人工复核——是 LLM 真正挣到位置的地方。也正是这些步骤,用规则引擎写会变成没人愿意维护的一千行 switch。

检索类的步骤——取当前生效的价目表、这个账户最近三次报价、当前毛利下限——都是 tool call,不是记忆。把检索结果塞进对话历史看起来省一次调用,实际上是给权威数据源复制了一份注定过期的副本。

编排器可以决定,但绝不能记忆。工作流需要的每一个事实都必须通过工具取回或从数据库读出,绝不沿着对话历史往下传。

这条规则是让失败模式变得可控的原因。对话历史是一个无界、未校验、无版本的状态存储。业务状态一旦住在里面,你就无法复现一次错误的运行,无法在改了 prompt 之后重放它,也无法在三周后回答“为什么它报了 12000 而不是 9600”。对话历史适合承载意图,不适合承载事实。

设计 MCP server,让模型无法即兴发挥

Model Context Protocol 在模型和你的系统之间划出一条有类型的边界。把工具面当成一个要面对敌意客户端的公开 API 来对待,因为事实就是如此:客户端是一个概率过程,它会递给你一个看起来合理、语义上却错误的参数。它还会在传输错误后重发同一条请求,会并发调用同一个工具,会在你没约束的地方自己发明字段。

下面是一个真实的报价 server,裁到只剩两个关键工具。

// mcp/quoting-server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { findActiveRateCard } from "@/lib/rate-cards";
import { createQuoteDraft } from "@/lib/quotes";

const server = new McpServer({ name: "quoting", version: "1.4.0" });

server.registerTool(
  "get_rate_card",
  {
    title: "Get active rate card",
    description:
      "Return the rate card in effect today for a customer tier. Call this before drafting a quote; rates change monthly and must never be assumed.",
    inputSchema: {
      tier: z.enum(["starter", "growth", "enterprise"]),
      currency: z.enum(["USD", "CNY"]).default("USD"),
    },
    annotations: { readOnlyHint: true, idempotentHint: true },
  },
  async ({ tier, currency }) => {
    const card = await findActiveRateCard(tier, currency);
    if (!card) {
      return {
        isError: true,
        content: [{ type: "text", text: `no_active_rate_card tier=${tier} currency=${currency}` }],
      };
    }
    return {
      content: [{ type: "text", text: `rate card ${card.rateCardId}, ${card.lines.length} lines` }],
      structuredContent: card,
    };
  },
);

server.registerTool(
  "create_quote_draft",
  {
    title: "Create quote draft",
    description:
      "Persist a draft quote and return its id. Requires the rateCardId you read, so a stale card is rejected. Pass the caller's idempotencyKey unchanged.",
    inputSchema: {
      accountId: z.string().uuid(),
      rateCardId: z.string(),
      lines: z
        .array(z.object({ sku: z.string(), quantity: z.number().int().positive() }))
        .min(1)
        .max(40),
      note: z.string().max(600),
      idempotencyKey: z.string().min(16),
    },
    outputSchema: {
      quoteId: z.string(),
      status: z.enum(["draft", "needs_review"]),
      subtotalCents: z.number().int(),
    },
    annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
  },
  async (args) => {
    const result = await createQuoteDraft(args);
    return {
      content: [{ type: "text", text: `draft ${result.quoteId} (${result.status})` }],
      structuredContent: result,
    };
  },
);

await server.connect(new StdioServerTransport());

那个文件里有三个决定是承重的。

rateCardId 是写工具的必填输入。模型没法编造价格,因为它没法编造自己正在依据的那一行数据;如果价目表在读和写之间轮换了版本,server 会拒绝这份草稿,而不是悄悄重新定价。

工具在人类可读文本之外同时返回 structuredContent。你的编排代码读结构化的那个字段;语言模型读哪个都行——让约束变成机器可读的产物,在检索场景里是同一条纪律。把两者混起来——去解析散文再把数字捞回来——你就会收到一张 12000 人民币、币种却标着 USD 的发票。

idempotentHint: true 是模型可以依赖的承诺。客户端在传输错误后重试时,同一个 key 返回同一份草稿,而不是第二份。

对应的协议消息就是普通 JSON。裁剪后的 tools/list 响应大致长这样:

{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "tools": [
      {
        "name": "get_rate_card",
        "description": "Return the rate card in effect today for a customer tier.",
        "inputSchema": {
          "type": "object",
          "properties": {
            "tier": { "type": "string", "enum": ["starter", "growth", "enterprise"] },
            "currency": { "type": "string", "enum": ["USD", "CNY"], "default": "USD" }
          },
          "required": ["tier"],
          "additionalProperties": false
        },
        "annotations": { "readOnlyHint": true, "idempotentHint": true }
      },
      {
        "name": "create_quote_draft",
        "inputSchema": {
          "type": "object",
          "properties": {
            "accountId": { "type": "string", "format": "uuid" },
            "rateCardId": { "type": "string" },
            "lines": { "type": "array", "minItems": 1, "maxItems": 40 },
            "idempotencyKey": { "type": "string", "minLength": 16 }
          },
          "required": ["accountId", "rateCardId", "lines", "idempotencyKey"]
        }
      }
    ]
  }
}

additionalProperties: false 和限长的数组,对可靠性的贡献超过任何数量的 prompt 指令。schema 已经强制约束住的东西,你事后就不必再测。

从 Next.js App Router 的 route 里把工作流流式输出

长工作流需要渐进式输出,而 Next.js 的 route handler 天然适合流式。关键纪律是:流里传的是事件,不是状态。客户端渲染路径上的任何东西都不会成为事实来源。反过来做,把流当状态用,客户端一刷新就丢上下文,服务端调一下渲染顺序就可能改出行为差异。

// app/api/quotes/draft/route.ts
import type { NextRequest } from "next/server";
import { claimRun } from "@/lib/runs";
import { runQuoteWorkflow } from "@/lib/workflow";
import { classifyError, isRetryable } from "@/lib/errors";

export const runtime = "nodejs";
export const maxDuration = 60;

export async function POST(req: NextRequest) {
  const { brief, accountId, tenantId, idempotencyKey } = await req.json();

  const run = await claimRun({
    idempotencyKey,
    tenantId,
    workflow: "quote.draft.v3",
    inputHash: await hashInput({ brief, accountId }),
  });

  if (run.state === "succeeded") {
    return Response.json({ replayed: true, result: run.result });
  }

  const encoder = new TextEncoder();
  const body = new ReadableStream<Uint8Array>({
    async start(controller) {
      const emit = (event: unknown) =>
        controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`));
      try {
        for await (const step of runQuoteWorkflow({ brief, accountId, tenantId, runId: run.id })) {
          emit(step); // { type: "token" | "tool_call" | "tool_result" | "usage", ... }
        }
      } catch (error) {
        emit({ type: "error", code: classifyError(error), retryable: isRetryable(error) });
      } finally {
        controller.close();
      }
    },
  });

  return new Response(body, {
    headers: {
      "content-type": "application/x-ndjson",
      "cache-control": "no-store",
      "x-run-id": run.id,
    },
  });
}

claimRun 在任何模型调用之前执行。这个顺序是刻意的:昂贵的工作必须挡在一次便宜的唯一性检查之后,否则两个浏览器标签页就会为同一张订单生成两张发票。

怎么让涉及钱的写操作能被安全重试?

幂等性是数据库的事,不是应用层的事。用唯一约束来保证,让数据库去仲裁。应用层自己维护的那套去重集合,在进程重启、多实例部署和并发请求面前都不成立。

// lib/runs.ts
import { db } from "@/lib/db";
import { runs } from "@/lib/schema";
import { eq } from "drizzle-orm";

export async function claimRun(input: {
  idempotencyKey: string;
  tenantId: string;
  workflow: string;
  inputHash: string;
}) {
  const [inserted] = await db
    .insert(runs)
    .values({ ...input, state: "running", startedAt: new Date() })
    .onConflictDoNothing({ target: runs.idempotencyKey })
    .returning();

  if (inserted) return inserted;

  const existing = await db.query.runs.findFirst({
    where: eq(runs.idempotencyKey, input.idempotencyKey),
  });

  if (!existing) {
    // Another transaction inserted and rolled back. Retry once, then surface it.
    throw new Error(`run_claim_race key=${input.idempotencyKey}`);
  }
  if (existing.inputHash !== input.inputHash) {
    throw new Error("idempotency_key_reused_with_different_input");
  }
  return existing;
}

inputHash 校验是大多数团队会跳过的那一步。没有它,一个复用了 key 但改过需求描述的客户端会拿回旧结果,还以为什么都对了。沉默的错误答案比响亮的报错更贵。

给这些 key 设一个保留窗口——用户触发的工作流通常 24 小时合适,如果合作方集成可能重放一批数据,那就 90 天。另外永远不要把 session cookie 或语言设置混进 key 里;key 应该标识意图,而不是标识一次渲染。

重试、退避,以及一份可执行的失败分类

没有分类这一步,重试逻辑就是没用的。重试一个校验错误是浪费;放着一个 429 不重试,就是用户能看见的抖动。

// lib/retry.ts
export async function withRetry<T>(
  operation: () => Promise<T>,
  { attempts = 5, baseMs = 400, maxMs = 8_000 } = {},
): Promise<T> {
  let lastError: unknown;

  for (let attempt = 0; attempt < attempts; attempt += 1) {
    try {
      return await operation();
    } catch (error) {
      lastError = error;
      if (!isRetryable(error) || attempt === attempts - 1) throw error;

      const serverHint = retryAfterMs(error); // honours Retry-After when present
      const exponential = Math.min(maxMs, baseMs * 2 ** attempt);
      const jittered = (serverHint ?? exponential) * (0.5 + Math.random());

      await new Promise((resolve) => setTimeout(resolve, jittered));
    }
  }

  throw lastError;
}
失败类别信号是否重试要修什么
限流HTTP 429,或供应商特有的过载码重试,遵守 Retry-After加队列;削平突发流量
瞬时传输故障socket 重置、网关返回 502/503重试,带 jitter固定区域;检查出网 NAT 上限
schema 违规工具参数未通过校验不重试收紧 schema;在工具描述里补一个具体示例
业务规则拒绝needs_review、价目表过期不重试转人工队列,不要让模型空转
上下文溢出prompt 超出窗口不重试插入之前先摘要检索到的文档
供应商故障某个模型持续 5xx重试一次,打到 fallback在同一套工具契约后面留第二个供应商

最后一行值得强调。一个挂在相同工具契约后面的 fallback 模型,是便宜的保险。因为编排器不持有状态、工具是强类型的,换模型是一次配置变更,不是一次重写。这也是我坚持把边界划清楚的主要原因,哪怕单模型原型写起来更快。它还顺手把供应商锁定从一个架构问题降级成一个配置问题。

可观测性:每次成功运行的成本

每次运行在进入终态的那一刻,写一条结构化记录。里面该有 run id、tenant、工作流版本、模型 id、输入输出 token 数、缓存命中 token、tool call 次数与耗时、重试次数、终态,以及以美分计的成本。把它和业务结果存在同一张表里,而不是塞进一条早晚会漂移的独立分析管道。分开存的那一刻,你就失去了把成本和结果关联起来的能力。

# inspect the tool surface on its own before a model ever touches it
pnpm dlx @modelcontextprotocol/inspector node dist/mcp/quoting-server.js

# one real run, then read the cost line the workflow emitted
curl -sS -X POST http://localhost:3000/api/quotes/draft \
  -H 'content-type: application/json' -H 'x-idempotency-key: 01HQ8Z6M4T9K2V' \
  -d '{"brief":"50 seats of onboarding video, zh-CN, 3 revisions","accountId":"8f2a","tenantId":"acme"}' \
  | tail -n 1 | jq '{state, out_tokens: .usage.output_tokens, cents: .cost.usd_cents}'

然后盯三个数字。P95 端到端延迟,因为均值会掩盖排队。每次成功运行的成本,因为一个对三分之一流量重试两次的工作流,实际开销是 happy path 的 1.6 倍。还有 needs_review 与自动完成的比例,因为复核率上升意味着输入分布已经漂移,而你的 prompt 正在自信地给出错误答案。

零边际成本不意味着什么

它不意味着这个工作流的运营是免费的。数据库、队列、出网流量,以及晚上十一点还在看 trace 的工程师,都得付钱。它意味着这些成本随基础设施增长,而不是随人头增长(这正是超级个体不靠人头扩张的前提),所以那个增量决策——多跑一次、多跑一万次——有一个你能提前算出来的答案。这个区别在预算会上很实在。

真正的交付物就是这个:一个成本可以预测的工作流,才是你能定价、能打包、能在合同评审里站得住的工作流。

收尾:先建边界,再写 prompt

从工具契约开始,而不是从 system prompt 开始。先写出工作流真正需要的那两三个工具,把输入约束到“错误调用不可能发生”,而不只是“不太可能发生”,把幂等键放进写路径,从第一次部署起就记录每次成功运行的成本。prompt 在第一个月里会改十几遍;工具边界如果设计得好,一次都不会改,其余的东西——模型选择、temperature、prompt 风格——都是在某个结构之上的调参。我见过的大多数失败案例,都不是 prompt 写得不好,而是边界从来没有被认真设计过。

继续阅读

更多「AI 系统架构」

准备构建一套系统?[ 预约会议 ]