← 返回

记录一下我在开发Agent时踩到的坑

复制为 Markdown

背景:过去一个月的时间,我在做 DB-Geniushttps://db-genius.com)——一个把自然语言变成 SQL、能跑多步数据库工作流、还能做跨库对比的 AI Agent(Spring Boot 3.4 + Spring AI 多模块项目)。这篇文章以「问题 → 解决方案」的形式,复盘开发过程中真正咬到我的 6 个坑。有些坑我们在项目里已经爬出来了,有些是爬到一半发现要动架构,也把调研后的最佳实践一并给出。

坑一:一定要多模型配置起手,不能只写死一个模型供应商

问题描述

刚开始做的时候,我的想法很朴素:接一个 DeepSeek,把 spring.ai.openai.*application.yml 里一配,全局一个 ChatClient 走天下。

然后现实很快打脸:

  • 用户不答应。模型供应商百家争鸣,有人手里是 DeepSeek 的 Key,有人买了 OpenAI 的 Team 版,有人公司内网只允许跑 Ollama 本地模型。用户都倾向于选择自己可配置的模型或 Token Plan——你写死一家,等于把其他用户拒之门外。
  • 账单算不清。不同模型的输入/输出单价完全不同(甚至思考 token 单独计价),没有计费规则配置化,商业化根本无从谈起。
  • 思考过程丢了。DeepSeek 这类推理模型的 reasoning_content 是非常有价值的数据(用户爱看、调优要用),SSE 流完就扔,等于把 Agent 最有信息量的部分倒掉了。
  • token 后知后觉。请求结束后才从最终响应里读 usage,意味着用户超支、上下文打爆这些事情发生后你才知道

解决方案

整体架构演进成下面这样(✅ 标记的是 DB-Genius 中已落地的,其余是按同一思路延伸的设计):

fig1-multi-model-arch.png

1)协议归一:全部采用 OpenAI 兼容协议

几乎所有主流供应商(DeepSeek、通义、智谱、Moonshot、Ollama……)都提供 OpenAI 兼容端点。DB-Genius 里所有供应商的 provider_type 统一为 openai_compatible,内置 deepseek / openai / ollama / custom 四个预设。用户的 Key 用 AES-256-GCM 加密后存进 user_model_config.api_key_encrypted,永不回显、永不进日志。

实际上现在很多 Code 相关的智能体也需要兼容 Claude 的协议,但是我们不是 Code 相关的,且主要面向国内市场,因此兼容 Claude 协议对我们收益不大。

2)模型会话动态构建,不用 yml 写死

项目里根本没有任何 spring.ai.* 配置,全部是运行时按用户配置构建:

java
// db-genius-agent/src/main/java/com/dbgenius/agent/ChatModelFactory.java
public ChatModelSession createSession(UserModelConfig config) {
    OpenAiApi openAiApi = OpenAiApi.builder()
            .baseUrl(config.getBaseUrl())
            .apiKey(EncryptUtil.decrypt(config.getApiKeyEncrypted()))  // 用时解密
            .build();
    OpenAiChatModel chatModel = OpenAiChatModel.builder()
            .openAiApi(openAiApi)
            .defaultOptions(OpenAiChatOptions.builder()
                    .model(config.getModelName()).build())
            .build();
    // 包一层思考链适配模型,再接两个 ChatClient
    ReasoningChatModel reasoningModel = new ReasoningChatModel(chatModel, openAiApi);
    return new ChatModelSession(chatClient, reasoningClient);
}

没有配置的用户走系统兜底(db-genius.model.default.*,也是环境变量注入)。ChatModelFactory 是整个多模型体系的枢纽——后面坑五的多模型路由,改的也是这个工厂的入参。

3)思考内容回传 + 落库

DeepSeek 开启 thinking 后有个非常隐蔽的要求:多轮工具调用时,下一轮请求必须把上一轮的 reasoning_content 原样带回去,否则模型行为退化(官方建议)甚至报错。我们在 ReasoningChatModel.toApiMessages() 里显式回写:

java
// db-genius-agent/src/main/java/com/dbgenius/agent/ReasoningChatModel.java
// ASSISTANT 消息转 API 消息时,把 metadata 里的思考内容写回 reasoning_content 字段
if (message.getMetadata().get("reasoningContent") != null) {
    apiMessage.setReasoningContent(...);   // 不回传 = 思考链断裂
}

落库侧,message 表专门留了字段(db-genius-web/src/main/resources/db/schema.sql):

sql
CREATE TABLE app.message (
    id                BIGSERIAL PRIMARY KEY,
    conversation_id   BIGINT       NOT NULL,
    role              VARCHAR(16)  NOT NULL,          -- user/assistant/tool
    type              VARCHAR(32)  NOT NULL,          -- user/step/tool/summary
    content           TEXT,
    reasoning_content TEXT,                            -- ★ 思考内容持久化
    tool_calls        TEXT,                            -- ★ 工具调用 JSON
    step              INT,                             -- 第几步(summary 为 -1)
    created_at        TIMESTAMPTZ  DEFAULT now()
);

4)实时 token 计量

流式场景下 DeepSeek/OpenAI 都支持在最后一个 chunk 里返回 usage(OpenAI 需要 stream_options.include_usage=true)。要做的三件事:

  1. 发起流式请求时带上 include_usage
  2. 聚合器(我们的 streamAggregated)在 .blockLast() 拿到末 chunk 后立刻提取 usage,通过 SSE 推一个 usage 事件给前端,前端实时累加显示「本次会话已消耗 xx tokens」;
  3. 同时写入一张 usage 流水表(taskId / userId / model / input_tokens / output_tokens / cost),单价放模型单价表里按供应商 + 模型配置,输入输出分开计价,计划(Token Plan)的额度校验和降级都基于这张流水表做。

小结

设计点 落地形态
多供应商接入 OpenAI 兼容协议 + provider 预设
用户级模型配置 user_model_config 表 + ChatModelFactory 运行时构建
计费可配置 模型单价表 + usage 流水表(按 taskId 归因)
思考内容 SSE 实时推 reasoning 事件 + message.reasoning_content 落库
实时 token 流式末 chunk 提取 usage,边推流边累计

坑二:SSE 打字机吞吞吐吐——先查缓存,尤其查 Nginx

问题描述

现象极其有迷惑性:本地起服务、直连 8109 端口调试,打字机丝般顺滑;一部署到测试环境,前端就开始「攒一段、蹦一段」——几个字几个字地往外蹦,偶尔还一次性吐出一大坨。

第一反应肯定是前端渲染问题,或者后端推流不及时。但 DB-Genius 的后端推流链路其实很简单直接:ToolCallAgent.think() 里每收到一个 reasoning delta 就立刻 emitter.send() 一个 SSE 事件:

java
// db-genius-agent/src/main/java/com/dbgenius/agent/ToolCallAgent.java
reasoningDelta -> sendEvent(emitter,
        SseEvent.of(taskId, currentStep, "reasoning", reasoningDelta))

后端是逐 token 推的,那问题只能是传输链路上有人还在缓存。

排查过程:一个 token 要经过 5 道关卡

fig2-sse-buffering.png

从模型到用户的眼睛,链路上每个节点都可能带缓冲:

  1. 后端框架层:响应压缩(gzip)有自己的内部缓冲窗口,会延迟 flush;
  2. 后端前置 Nginxproxy_buffering 默认开启,会把上游响应攒进 buffer 再按块发给客户端——SSE 流直接被掐成一段一段;
  3. 前端静态资源服务器 / 网关 / CDN:前后端分离部署时,浏览器到后端之间往往还隔着一台前端 Nginx,跨了团队、跨了机器,最容易漏配
  4. 浏览器侧:如果前端用 await res.text() 之类的整包读取,那前面全白搭(要用流式 reader 或 EventSource)。

这里还有一个更隐蔽的连环坑:我们早期把 Agent 跑在 ForkJoinPool.commonPool() 上(CompletableFuture.runAsync 默认线程池),commonPool 大小只有 CPU 核数 - 1,几个分钟级的长任务一并发,事件全部堵在线程池里成批到达——症状和缓冲一模一样!后来专门定义了 chatTaskExecutor(core 8 / max 32 / queue 200)才算根治:

java
// db-genius-web/src/main/java/com/dbgenius/web/config/ChatExecutorConfig.java
@Bean(destroyMethod = "shutdown")
public Executor chatTaskExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(8);
    executor.setMaxPoolSize(32);
    executor.setQueueCapacity(200);
    executor.setThreadNamePrefix("chat-task-");
    ...
}

教训:「成批到达」≠「缓冲」,先确认事件从后端出来的节奏(看日志时间戳或 MDC taskId 链路),再去怀疑传输层。

解决方案:逐层兜底

① 后端响应头声明免缓冲(DB-Genius 已落地,ChatController.java):

java
@PostMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter chat(...) {
    response.setHeader("Cache-Control", "no-cache");
    response.setHeader("X-Accel-Buffering", "no");   // ★ Nginx 官方约定的免缓冲头
    ...
}

X-Accel-Buffering: no 是 Nginx 认的「官方暗号」,带上它,就算运维没改配置,Nginx 也不会缓冲这个响应。这是成本最低、收益最大的一步

② Nginx 显式配置(别只依赖响应头,运维侧也要配):

nginx
location /api/chat {
    proxy_pass http://backend;
    proxy_buffering off;        # 关闭响应缓冲
    proxy_cache off;            # 关闭缓存
    gzip off;                   # SSE 路由别压缩
    proxy_http_version 1.1;
    proxy_set_header Connection '';
    proxy_read_timeout 600s;    # Agent 任务是分钟级的,别让 Nginx 先超时
}

③ 前端流式读取:fetch + res.body.getReader() 或 EventSource,拿到 chunk 就渲染,别等整包。

④ 验证手段:浏览器 DevTools → Network → 选中 SSE 请求 → 看 Timing / EventStream 标签,事件是不是逐条到达一目了然;怀疑哪一层就在哪一层前后各抓一次。


坑三:执行轨迹要落库、要透明,更要可观测

问题描述

Agent 和普通 CRUD 系统最大的区别是:它的核心价值在「过程」里。用户问「对比预发和生产库的差异」,Agent 思考了 3 步、调了 2 次 db_compare 工具、又执行了一次 dry-run SQL——这个轨迹:

  • 用户想实时看到(不然就是一个转圈 30 秒的黑盒,没人敢信它);
  • 出了问题要能回放(模型当时为什么这么决策?);
  • 最关键的一点:团队要评估和 benchmark(换了个模型,成功率升了还是降了?平均几步完成任务?)。

DB-Genius 做到了前两件事的一半:14 种 SSE 事件(classifying / classified / reasoning / content / sql / result / step / summary / done……)实时透明地推给前端,step/tool 类型的消息连同 reasoning_contenttool_calls 一起落库。但「研发侧的可视化」我们是缺失的——线上唯一的追踪手段是 MDC 里的 taskId 串日志。日志能告诉你「发生了什么」,但很难告诉你「一次任务全貌长什么样、钱花在哪一步」

关于第三点,我们正在评估最优的解决方案,不久之后就会更新,请关注 github 仓库动态。

解决方案:OpenTelemetry 给每一步打 span

业界现在的事实标准是 OpenTelemetry(OTel),而且 GenAI 语义约定(gen_ai.* 属性)已经逐渐稳定。思路:把一次 /chat 请求作为根 span,意图分类、每一轮 think、每一次工具执行、最终 summary 都是子 span,关键指标挂在 span 属性上:

fig3-otel-trace.png

埋点示例(手动 span,也可以用 OTel Java Agent 零代码接入 + 关键位置补属性):

java
@WithSpan("agent.think")
public boolean think() {
    Span span = Span.current();
    span.setAttribute("agent.name", name);
    span.setAttribute("agent.step", currentStep);
    span.setAttribute("gen_ai.system", "deepseek");
    span.setAttribute("gen_ai.request.model", model);
    // ... 流式聚合完成后回填:
    span.setAttribute("gen_ai.usage.input_tokens", inputTokens);
    span.setAttribute("gen_ai.usage.output_tokens", outputTokens);
}

@WithSpan("agent.tool.sql_execute")
public String executeSql(String sql) {
    Span.current().setAttribute("db.statement", sql);   // 注意脱敏
    // 工具结果大小、耗时、是否被截断…
}

Jaeger(自建、免费)或 Langfuse(LLM 原生、自带评估数据集管理)做后端,一次「对比预发与生产库」的请求就变成了图里那样的瀑布:分类 600ms → 第一步思考 1.1s(reasoning 3.2k tokens)→ db_compare 700ms → 第二步思考 1.0s → dry-run 500ms → summary 650ms。

有了这层数据,三件事顺理成章:

  1. 能力评估:按 trace 聚合平均步数、工具调用成功率、无效思考占比;benchmark 数据集重放后对比成功率曲线——换模型、改提示词的效果从「感觉变好了」变成可量化的数字。
  2. 成本归因:每个 span 带 token usage,一次任务花多少钱、哪一步最贵一目了然,直接反哺坑一的计费体系。
  3. 线上排障taskId 把 MDC 日志、SSE 事件流、OTel trace、message 表四者串成一条线,用户报障秒级定位到具体某一步。

坑四:上下文压缩——不要低估用户的体量和操作环境

问题描述

这个坑是我们差点踩实的。DB-Genius 目前的上下文管理只有一招:按条数截断——意图分类带最近 5 条历史,Agent 带最近 10 条。看起来很合理,对吧?

但算一笔账就笑不出来了:

  • 用户接入的库文档 doc_content 可能 30,000+ 字符(我们的文件解析截断上限就是这个数),每次都注入 system 提示词;
  • 每步工具执行结果最多回填 100 行 SQL 结果
  • 用户在一个会话里连续问十几轮、再上传两个 Excel……

「最近 10 条」完全不代表「10 条很短」。按条数控制上下文,就像按「袋数」控制行李重量——一架货运无人机和一袋棉花都叫一袋。等模型返回 400「context length exceeded」时,用户已经聊到兴头上了,这个体验是灾难性的。而且千万不要低估用户操作的环境:用户连自己都不知道自己的库有 800 张表、文档几十万字。

解决方案:计量 → 触发 → 压缩 → 引导

fig4-context-compaction.png

① 先能计量。没有数字就没有压缩。给每条消息估算 token:OpenAI 系用 tiktoken(Java 有 jtokkit),其他模型用「字符数 ÷ 经验系数」兜底,中文场景约 1 字 ≈ 0.6~1 token。system 提示词、库文档、每条历史、每个工具结果都打上 token 数标签。

② 分层压缩策略(窗口占用超阈值时逐层动用):

策略 说明
system 提示词 永驻不动 方言提示 + 安全红线,是 Agent 的行为基础
库文档 按需裁剪 只注入当前涉及的表结构,全量文档放工具里按需查
旧对话 LLM 滚动摘要 「已确认 users/orders 结构,预发多 3 张表…」,落库 type=compact
工具结果 只留结论 100 行原始结果 → 3 行发现(原文本就在 message 表里,不怕丢)
最近 K 轮 原文保留 短期记忆不能压缩,否则答非所问

③ 前端刻意引导。这是最容易被忽略的一环:压缩不应该只是后端静默做的事。当窗口占用到 80%:

  • 前端亮起引导条:「上下文将满,建议压缩后继续,或开启新会话」;
  • 用户点「压缩上下文」→ 后端执行摘要 → SSE 推 compacted 事件,前端展示「已压缩 xx tokens,保留最近 x 轮」;
  • 压缩要有保留点机制——用户标记的关键结论(比如最终选定的修复 SQL)不参与压缩。

把「即将超限」变成一个可见、可操作的状态,而不是一次突如其来的 400 报错,体验天差地别。这也是 Claude Code、Cursor 这类成熟产品的共同做法:auto-compact 兜底 + 用户可手动 /compact


坑五:多模型协同——让合适的模型干合适的活

问题描述

坑一解决的是「用户能选模型」,这个坑是它的另一面:单一模型包打不了天下

  • 有的模型是纯文本模型,写代码、生成 SQL 很厉害,但看不了图。用户截一张报表截图问「照这个把 ER 设计图画出来」,纯文本模型只能干瞪眼;
  • 反过来,用户用文本模型完成了库对比、产出了结论,下一步很自然想要一张设计图/迁移说明图——文本模型又画不出来;
  • 还有成本维度:意图分类、上下文摘要这种轻活,用旗舰推理模型跑,就像开法拉利送外卖——DB-Genius 的 IntentClassifier 显式关掉 thinking("thinking": {"type": "disabled"})就是被账单教育出来的。

如果 Agent 只能抱着一个模型硬扛,用户体验的上限就被这个模型的短板钉死了。

解决方案:Model Router + 能力元数据 + 接力编排

fig5-multi-model.png

核心是在 Agent 编排层加一个 Model Router,按三个要素派发:

  1. 任务类型:闲聊/分类/摘要 → 轻量模型;SQL/代码 → 旗舰推理模型;
  2. 模态:检测到输入带图片 → 路由到视觉模型(或走 OCR 降级——DB-Genius 现在用 ImageReadTool 把图片 OCR 成文本喂给文本模型,本质是「穷人版多模态」,能用,但 UI 还原这类任务效果差很多);产出物是图 → 接力图像生成模型;
  3. 成本策略:用户的 Token Plan 决定能用哪一档模型,超额自动降级。

落地有三个关键点:

① 模型能力元数据表。路由不能靠硬编码 if-else,要有数据支撑:每个模型的模态(text/vision/image-gen)、上下文窗口、输入输出单价、速度档位、供应商,全部入库可配置。

② 复用 ChatModelFactory。DB-Genius 的 ChatModelFactory.createSession() 本来就是「按配置动态构建模型会话」——把入参从「用户配置的单一模型」扩展为「Router 按任务解析出的模型」,改造面非常小(再次印证坑一里「计量点/构建点收敛」的价值)。

③ 接力要有「交接棒」协议。跨模型协作时,上一棒的产出必须是结构化的:文本模型产出库对比结论(Markdown + 结构化 JSON),生图模型拿 JSON 里的表/字段/关系去渲染 ER 图。自由文本当交接棒,接力质量完全不可控。主动接力之外,也要引导用户——产出结论后前端给出「🎨 生成设计图」「📊 生成对比报告」的快捷动作,让用户感知到「这个 Agent 不止会聊天」。


坑六:工具调用时模型输出一堆奇怪的文案——过滤永远追不上降级,用兜底机制切断强化环

这一步是 DeepSeek 模型也有的,其它模型也有差不多的问题,根因在工具调用的时候工具给模型返回的异常信息模型看不懂。

问题描述

Agent 的回复中总是莫名其妙地出现下面的内容:

text
<||DSML||tool_calls>
<||DSML||invoke name="executeSql">
<||DSML||parameter name="dbConfigId" string="false">1</||DSML||parameter>
<||DSML||parameter name="sql" string="true">SHOW TABLES</||DSML||parameter>
</||DSML||invoke>
</||DSML||tool_calls>

DSML 是 DeepSeek 内部的「文本形式工具调用」标记。它被原样打到用户面前,说明模型在总结轮次没有正常输出总结,而是试图用纯文本「继续调用工具」。而且因为坑二里我们把总结做成了打字机流式推送,这段咒语是逐字蹦出来的,想装没看见都难。

第一反应肯定是「输出脏了,加个正则过滤掉」。我们在更早的项目里真这么干过,提交记录就是一部血泪史:

做法 评价
正则过滤 DSML 文本,不返回给前端 治标。标记变体太多(全/半角竖线混用、残缺闭合、简化 <invoke> 写法),兜不住
流式 buffer 里检测 DSML 起始片段 仍属输出层拦截,换个变体又漏
把 DSML 反解析回真实工具调用 真正有效的部分——修的是工具调用链路本身
总结轮 tools=null + toolChoice=none 降低但不消除风险

教训一句话:过滤输出永远追不上模型的降级变体。你得先搞清楚模型为什么开始「说胡话」。

根因分析:泄漏是果,空参执行才是因

线上完整日志(task 90e6e21a)给出了关键证据链:

text
[DbSqlAgent] selected 1 tools: executeSql
SqlExecuteTool - Executing statement on db null: null        ← 工具被以空参数执行
act results: Tool executeSql result: "Error: Database config not found for ID null"
(约 3 秒后)
INSERT message role=assistant type=summary content=<DSML invoke executeSql ...>

即:工具确实被调用了,但参数全为 null;真实参数躺在模型正文的 DSML 文本里,从未被解析。 把时间线拉直,是一个自我强化的恶性循环:

fig6-dsml-loop.png

  1. 模型偶发降级:DeepSeek 在 thinking + 流式下,偶发把工具调用「降级」——结构化 tool_calls 还在但 arguments 为空,真实参数被写进 content 的 DSML 标记;
  2. 框架忠实执行空参调用act() 不做参数校验,空参调用直接交给工具,executeSql 收到 dbConfigId=null
  3. 错误反馈是噪音config not found for ID null 与模型「自认传了参数」的事实矛盾,模型的协议认知被扰乱;
  4. 模型放弃结构化协议,改用纯文本 DSML「重试」工具调用;
  5. 时机放大伤害:事故发生在最后一步(step = maxSteps),模型只剩一次不带 tools 的总结调用,于是在总结里用 DSML 文本「继续任务」——这段文本随 summary 事件推给前端并落库。

一句话:DSML 泄漏是果,空参工具调用被执行、模型拿到不可理解的错误才是因。 这也解释了一个反直觉的事实:错误反馈对模型而言是上下文——噪音错误会把模型推向更糟的降级,可理解的错误才是自愈的前提。

解决方案:治本为主、兜底为辅的四层纵深

fig7-defense-in-depth.png

① DSML 反解析恢复(治本,主修复)

新增 DsmlToolCallParserparse(content) 把 DSML invoke 块解析为 (name, argumentsJson),参数类型按 string 属性还原(string="false"1 必须是 JSON 数字,否则 executeSql(Long dbConfigId) 反序列化仍会失败);正则统一用 [||] 兼容全/半角竖线。ToolCallAgent.think() 在拿到流式聚合响应后调用恢复逻辑:

java
// db-genius-agent/src/main/java/com/dbgenius/agent/ToolCallAgent.java
// 流式聚合完成后先做 DSML 恢复:
//  - 结构化 tool_calls 在但 arguments 空白 → 按顺序用反解析结果填补参数
//  - 完全没有结构化调用 → 合成新调用(id 为 dsml-i)
//  - content 经 strip 净化后重建 AssistantMessage(保留 metadata,reasoning 回传不受影响)
AssistantMessage recovered = recoverDsmlToolCalls(aggregated);
// 恢复触发时打 warn:Recovered N DSML tool call(s),线上可统计命中率

关键是最后一点:消息历史保持结构化协议干净,不再向后续轮次暴露 DSML 文本——切断「教坏模型」的强化环。

② 空参护栏(恢复失败的兜底)

act() 执行前校验每个 tool call 的 arguments(非空白且为合法 JSON 对象)。仍有非法调用时绝不执行空参调用,而是手工把 assistant 输出与合成的 ToolResponseMessage(id 与 tool call 配对,满足协议校验)写入消息历史,工具结果是一条可行动的重试指引:

json
{"success":false,"error":"Tool arguments were empty or invalid. Re-emit the tool call with all required parameters."}

模型拿到的是能理解、能纠正的反馈,而不是 config not found for ID null 式噪音。注意①②两层都是供应商无关的通用防御——不含 DSML 时走零开销路径,对其他模型无副作用;今天 DeepSeek 降级出 DSML,明天别家模型降级出别的怪东西,护栏照样接得住。

③ 总结提示词硬约束(次要防御)

SUMMARY_SYSTEM_PROMPT 增加规则:总结轮没有工具,禁止输出任何工具调用标记,只输出 Markdown。从生成源头降低概率,但不把它当主力。

④ 总结输出安全网(最后兜底,明确不是主修复)

generateMarkdownSummary 的聚合全文经 DsmlToolCallParser.strip()(四层正则:完整块 → 孤立闭合标签 → 简化块 → 残缺片段)后再发终态 summary 事件/落库;剥离后为空走 fallbackSummary。打字机增量可能仍会闪过原文,但终态事件是前端渲染的权威内容,会整体覆盖增量——用户最终看到的永远是干净内容。

分层定位一定要清楚:①②是主修复,③④是纵深防御。若未来再现泄漏,先查恢复日志(Recovered N DSML tool call(s))是否命中,再谈过滤

为什么必须强调多场景测试

这个坑对测试观的教育意义,比代码修复本身更大:

  • 偶发 + 组合触发,手工复现几乎不可能。DSML 降级只在 DeepSeek「thinking + 流式」组合下偶发;非流式、关 thinking 的测试全绿,不等于线上安全。所以回归手段是用单测模拟模型的异常输出形态:空参 + DSML 同现、纯 DSML 无结构化调用、空参且无 DSML 三种形态(DsmlToolCallParserTest / ToolCallAgentTest),把线上事故固化成测试夹具。
  • 变体必须全覆盖。全/半角竖线、简化 <invoke>、残缺未闭合片段、string="true""42" 要保持字符串、重建消息必须保留 metadata(丢了 DeepSeek thinking 回传直接 400)——每一个变体都是线上真实咬过人的 case,漏一个就等于把事故留着再炸一次。
  • 时机决定伤害,测试要覆盖「边界步骤」。同样的异常发生在中途步骤,模型还有轮次自愈;发生在最后一步就直通用户。只测「中间步骤」的用例,测不出最疼的那条路径。
  • 交付形态变化本身就是一次回归场景。总结一次性到达时没人注意异常,打字机化后逐字暴露——坑二里那个「丝般顺滑」的流式优化,在这个坑里成了放大镜。每改一次交付形态(流式/非流式、一次性/增量),都应该把异常路径重新过一遍。

💡 兜底机制是系统属性,不是补丁:模型的异常输出形态永远比你想的多,「见一个过滤一个」必输。把恢复、护栏、清洗做成供应商无关的通用防线,配一个覆盖多形态、多变体、多时机的测试矩阵,才睡得着觉。

小结

设计点 落地形态
治本 DsmlToolCallParser 反解析恢复真实工具调用,净化消息历史,切断强化环
护栏 act() 空参校验,绝不执行空参调用,回可行动的重试指引
防御 + 安全网 总结提示词硬约束;终态 summary 经 strip 清洗,空则 fallbackSummary
回归 三形态单测 + 变体正则全覆盖 + 线上 warn 日志统计命中率

总结:一张自检清单

把这 6 个坑浓缩成开工前的自检清单,新项目对照着过一遍,能少掉很多头发:

  • [ ] 模型接入是否收敛在一个工厂/适配层?加一个新供应商要不要动业务代码?
  • [ ] 计费规则(单价、套餐、降级)是不是配置化的?usage 是不是流式过程中实时累计的?
  • [ ] 思考内容(reasoning)有没有回传给下一轮、有没有落库?
  • [ ] SSE 链路每一层的缓冲/压缩/超时都确认过吗?X-Accel-Buffering: no 加了吗?
  • [ ] 长任务跑在专用线程池了吗?(别用 ForkJoinPool commonPool)
  • [ ] Agent 的每一步有 span 吗?能用 trace 回答「这次任务几步、花了多少 token、卡在哪」吗?
  • [ ] 上下文是按 token 计量还是按条数裸截断?窗口将满时前端有引导吗?
  • [ ] 图片输入/图片产出有没有模型接力方案,还是在让纯文本模型硬扛?
  • [ ] 工具调用执行前校验参数了吗?空参/非法参数是照执行不误,还是回给模型一条可理解、可纠正的反馈?
  • [ ] 模型降级输出(空参、文本态工具调用、怪标记)有供应商无关的通用兜底吗?异常形态、变体、边界步骤都进了回归测试矩阵吗?

开发 Agent 相对于原先的 CRUD 最大的不同:它把分布式系统、流式传输、成本工程、人机交互的问题全都揉在了一起。坑踩完了,产品也就有骨架了。希望这份记录能帮到正在从后端转型到 Agent 开发的兄弟姐妹。

最热文章