LangChain4j 调用 DeepSeek 工具时报 400?用 pi 抓包定位,同包覆盖修复 reasoning_content

背景

使用 LangChain4j 搭配 OpenAI 的 starter 进行工具调用时,会出现 400 Bad Request:

▼
json
复制代码
{"error":{"message":"The `reasoning_content` in the thinking mode must be passed back to the API",...}}

问题分析

出现这个问题,是因为发送的 request body 不符合 DeepSeek 的规范。那么缺少的是哪一个字段?光靠猜并不靠谱,这里我们用一个非常简易的 Agent 框架 pi 来抓真实请求体。

安装 pi

pi 的安装非常简单:

▼
bash
复制代码
npm install -g --ignore-scripts @earendil-works/pi-coding-agent

安装之后,在终端输入 pi 即可看到:

image.png

配置 DeepSeek API Key

配置 API Key 也很简单:输入 /login → Use an API Key → 选择 DeepSeek,再输入密钥。

image.png

输入密钥后回车确认即可:

image.png

用扩展抓取真实请求体

下面是我让 pi 生成 ai-request-logger 扩展用的 Prompt,它会把所有 AI provider 请求/响应落盘到 .pi/ai-request-logger/ 下。

▼
markdown
复制代码
# Prompt: 生成 ai-request-logger 扩展 为 pi coding agent 创建扩展,拦截并记录所有 AI provider 请求/响应到 `.pi/ai-request-logger/` 目录下按日期分 `.jsonl` 文件。 **核心功能:** 1. `before_provider_request` → 记录请求 ID、模型、消息数、payload 大小 2. `after_provider_response` → 追加状态码、延迟 3. `message_end` → 追加 token 用量、费用 4. `turn_end` → 汇总本轮统计,footer 显示 5. 注册 `/ai-log` 命令 → custom UI 面板查看日志(滚动/展开) 6. 注册 `query_logs` 工具 → LLM 可查询统计/历史 7. 注册 `log_level` 工具 → LLM 调整日志级别 **实现要求:** - 内存维护 `RequestLog[]` 和 `TurnSummary[]`,上限 1000 条 - 文件写用 `fs.promises.appendFile`,不阻塞 - 完整 payload 仅 verbose 模式存储 - 参考示例:`provider-payload.ts`、`todo.ts`、`summarize.ts`、`model-status.ts` - 所有 I/O try-catch 包裹,不抛异常阻塞主流程

通过 pi 进行工具调用时,他就会吧日志信息记录到 .pi 文件夹下面:

image.png

我们可以看到工具调用会添加一个 reasoning_content 字段,并且这个 content 字段为 null 也不影响:

image.png

定位根因

我们查看自己的请求体,发现没有这个参数,所以报错就是因为缺少 reasoning_content。知道原因后,修改就容易了。

修复 Bug

通过同包名覆盖 dev.langchain4j.model.openai.OpenAiChatModel,在 DeepSeek 模型分支下回传 reasoning_content 字段。我们使用同包名覆盖源码的方式实现:JVM 类加载时,工程内同包同名类会优先于依赖包中的版本。这种方式虽然不利于版本升级,但最直接有效。灵感来源于「AI 零代码项目」,具体方法如下:

  1. 首先找到相对底层的类 dev.langchain4j.model.openai.OpenAiChatModel
  2. 在项目的同包路径 src/main/java/dev/langchain4j/model/openai/ 下创建同名类 OpenAiChatModel,并把源码内容复制过来
  3. 之后可以让 AI 进行修改,可以使用 Cursor 或者 Codex 之类的,让工具调用时添加上 reasoning_content 这个参数
  4. 下面是我修改好的代码片段,完整版本见 GitHub 上的 OpenAiChatModel.java:
▼
java
复制代码
@Override public ChatResponse doChat(ChatRequest chatRequest) { OpenAiChatRequestParameters parameters = (OpenAiChatRequestParameters) chatRequest.parameters(); validate(parameters); String modelName = parameters.modelName(); List<Message> messages = isDeepSeekModel(modelName) ? toOpenAiMessages(chatRequest.messages(), sendThinking, thinkingFieldName) : OpenAiUtils.toOpenAiMessages(chatRequest.messages(), sendThinking, thinkingFieldName); ChatCompletionRequest openAiRequest = toOpenAiChatRequest( chatRequest, parameters, sendThinking, thinkingFieldName, strictTools, strictJsonSchema) .messages(messages) .build(); .... .... } private static boolean isDeepSeekModel(String modelName) { return modelName != null && modelName.toLowerCase().contains("deepseek"); } /** * DeepSeek V4 thinking + tool_calls:含 tool_calls 的 assistant 必须回传 reasoning_content(无则 "")。 */ private static List<Message> toOpenAiMessages( List<ChatMessage> messages, boolean sendThinking, String thinkingFieldName) { return messages.stream() .map(message -> toOpenAiMessage(message, sendThinking, thinkingFieldName)) .collect(toList()); } private static Message toOpenAiAssistantWithToolReasoning(AiMessage aiMessage, String thinkingFieldName) { String reasoning = aiMessage.thinking(); if (reasoning == null) { reasoning = ""; } ToolExecutionRequest first = aiMessage.toolExecutionRequests().get(0); if (first.id() == null) { FunctionCall functionCall = FunctionCall.builder() .name(first.name()) .arguments(first.arguments()) .build(); return AssistantMessage.builder() .functionCall(functionCall) .customParameter(thinkingFieldName, reasoning) .build(); } List<ToolCall> toolCalls = aiMessage.toolExecutionRequests().stream() .map(it -> ToolCall.builder() .id(it.id()) .type(FUNCTION) .function(FunctionCall.builder() .name(it.name()) .arguments(isNullOrBlank(it.arguments()) ? "{}" : it.arguments()) .build()) .build()) .collect(toList()); return AssistantMessage.builder() .content(aiMessage.text()) .toolCalls(toolCalls) .customParameter(thinkingFieldName, reasoning) .build(); }

测试

Github 上面提供了一个简单的 demo,测试发现是可以的非常成功!通过日志可以看到正确携带了 reasoning_content 字段。

image.png

相关专栏

0个评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
leikooo
作者分享
IP 数据库体积缩小 31%,关键竟然是合并重复子树
2
提到用 Rust 重写项目,很多人的第一反应往往是性能提升。以 Bun 为例,它最初主要采用 Zig 开发,后来在 2026 年发布的 v1.4 版本中将核心代码迁移到了 Rust。用 Rust 重写,性能、安全性往往是大家想要的,但是有没有想过用 Rust 重写还能发现 bug 呢? pgrust 这个项就是,他用 Rust 重新实现 PostgreSQL,保持通信协议和 SQL 方言兼容。为了确认新系统的行为和原版一致,开发者需要对着 PostgreSQL 的实际返回逐个比对。结果查着查着,有些异常不是新代码的锅,PostgreSQL 自己就是这样的。pgrust 开发者 Michael Malis 把其中几个报告给了 PostgreSQL 社区。 大概的问题: 1)SP-GiST 空间索引里有个函数判断点的方位,代码认为上、下、同一水平线三种情况已经穷尽了,末尾放了句「impossible case」兜底。结果在特定浮点边界附近,三种判断同时不成立,程序真走进了这个「不可能」的分支 2)日期格式缓存复用时,一个解析模式的标记没重置,导致同一条 JSONPath 查询在同一个会话里,换个执行顺序结果就不一样。 3)pgcrypto 的 crypt() 在某些输入下截断了多字节字符,返回的文本编码不合法。能存进去,但 pg_dump 导出时就炸了。 一个用 Rust 重写的新项目,测着测着,把运行了几十年的 PostgreSQL 里埋着的东西翻了出来。Rust 重写的价值,不一定只在跑得更快、更安全这一件事上。为了做到真正的兼容,开发者得把前人没怎么碰过的边界全走一遍,这个过程本身就在帮原版排雷。
4
ARTS 1025: 唯一字符与同构字符串、省词可能增加阅读负担与重新估算软件的实现成本
5
🎉 1024 程序员节快乐! 祝编程导航里的每一位程序员:offer 多多,薪资翻倍,面试顺利,早日拿下心仪 offer!💻
17
AST:从 Java 代码理解抽象语法树
5
下载 APP