LangChain4j声明式Agent架构深度解析:动态代理与函数回调机制

1. 为什么值得深挖 LangChain4j 的声明式 Agent 架构

第一次看到 LangChain4j 的@Agent注解时,我的反应和大多数人一样:这不就是个语法糖吗?声明一个接口,打上注解,框架帮你生成实现类,背后无非是 JDK 动态代理那一套。但真正把源码翻了一遍、又踩了几个坑之后,我发现事情远没有这么简单。这套声明式 Agent 架构背后,藏着一条从接口代理到大模型函数回调再到工具编排的完整链路,理解它之后,你对"AI 应用到底怎么调用外部能力"这件事的认知会上一个台阶。

这篇文章要聊的就是这条链路。我会从设计动机讲起,拆解动态代理在其中的角色,再深入到函数回调的底层机制,最后给出可以直接抄的实操方案和排查经验。适合两类人看:一类是已经在用 LangChain4j 但只会照着文档写AiServices的开发者,另一类是想自己造一套类似 Agent 框架、需要搞清楚底层怎么设计的中高级工程师。哪怕你之前没接触过 LangChain4j,只要写过 Java 接口和注解,也能跟下来。

先说结论:声明式 Agent 的本质,是把"大模型调用 + 工具执行 + 结果回填"这个循环,用动态代理封装成了一次普通的接口方法调用。你写的是assistant.chat("帮我查下天气"),框架在背后帮你完成了提示词组装、模型请求、工具识别、函数执行、多轮循环、结果解析这一整套动作。听起来简单,但每一环都有讲究,尤其是函数回调那部分,是整个架构里最容易出问题、也最值得深挖的地方。

2. 声明式 Agent 到底解决了什么问题

2.1 从命令式调用到声明式接口的演进

在没有声明式 Agent 之前,你要让大模型调用一个外部工具,代码大概长这样:手动构造ChatLanguageModel,手动拼UserMessage和SystemMessage,手动把工具描述塞进请求,拿到响应后判断是不是tool_calls,如果是就解析参数、反射调用方法、把结果包装成ToolExecutionResultMessage再发一轮请求,循环直到模型不再要求调用工具为止。这一套写下来少说五六十行,而且每个 Agent 都要重复一遍。

声明式 Agent 把这个过程压缩成了一个接口:

interface Assistant { @SystemMessage("你是一个乐于助人的助手") String chat(String userMessage); }

然后AiServices.create(Assistant.class, model)一行拿到实例,直接调用。框架帮你把上面那五六十行全做了。这就是声明式编程的典型价值:你描述"要什么",框架负责"怎么做"。命令式代码里那些样板逻辑——消息组装、循环控制、工具分发——全部被下沉到框架内部。

这个演进路径其实和 Spring Data JPA 很像。以前写 DAO 要手写 SQL、手动映射结果集,后来你只需要声明一个findByUsername方法签名,框架帮你生成实现。LangChain4j 的声明式 Agent 走的是同一条路:方法签名即意图,注解即配置,代理即实现。

2.2 动态代理在这里扮演的角色

为什么是动态代理而不是代码生成?这是个值得琢磨的设计选择。代码生成(比如注解处理器 APT)在编译期就把实现类写出来,性能好、可调试,但缺点是灵活性差——工具列表、模型配置这些往往在运行时才能确定,编译期生成搞不定动态注册。而动态代理在运行时拦截方法调用,可以随时根据上下文决定怎么处理,天然适合 Agent 这种"配置驱动、运行时组装"的场景。

LangChain4j 用的是 JDK 动态代理(java.lang.reflect.Proxy),这意味着你的 Agent 必须定义成接口,不能是类。这一点很多人第一次用会踩坑:写了个抽象类打注解,结果报错说不支持。原因就在这——JDK 代理只能代理接口。框架内部通过Proxy.newProxyInstance创建代理对象,所有方法调用都会被InvocationHandler拦截,然后在 handler 里完成整个 Agent 逻辑。

提示:如果你的 Agent 需要继承某个类,JDK 动态代理满足不了,得换 CGLIB 那套。但 LangChain4j 目前就是接口路线,设计上更轻量,也避免了字节码增强带来的复杂度。

2.3 声明式带来的三个实际收益

第一个收益是可测试性。因为 Agent 是接口,你可以轻松 mock 它,单元测试里不需要真的连大模型。第二个收益是类型安全。方法参数和返回值都是强类型的,不像拼字符串那样容易出错,框架还能根据返回类型自动做 JSON 反序列化。第三个收益是可组合性。多个 Agent 接口可以互相注入,一个 Agent 调用另一个 Agent,形成层次化的智能体结构。

这三点里,我觉得类型安全是最容易被低估的。举个例子,你定义一个返回WeatherReport对象的方法,框架会自动把模型的文本输出解析成这个对象,解析失败还会重试。如果换成命令式代码,你得自己写 JSON 解析、自己处理格式错误,工作量差好几倍。

3. 动态代理到函数回调的完整链路拆解

3.1 代理对象的创建与 InvocationHandler 拦截

AiServices.create(Assistant.class, model)这行代码背后,框架做了一件关键的事:构建一个InvocationHandler,然后用它创建代理实例。这个 handler 里持有几样东西——模型引用、工具列表、聊天记忆、系统消息模板等等。当你调用assistant.chat("...")时,请求先进入 handler 的invoke方法。

invoke方法里做的事情可以概括为四步:解析方法元数据(读注解、看参数)、组装请求(把用户输入、系统消息、工具描述拼成模型能懂的格式)、发起调用并处理循环(这是核心,下面细讲)、转换返回值(把模型输出变成方法声明的返回类型)。整个过程对调用方完全透明,你感觉就像在调一个普通方法。

这里有个细节值得注意:框架会缓存方法元数据的解析结果。因为反射读注解是有开销的,如果每次调用都重新解析一遍,高频场景下性能会受影响。缓存之后,同一个方法的元数据只解析一次,后续直接复用。这个优化在源码里不一定显眼,但对性能敏感的应用很关键。

3.2 工具注册:注解扫描与函数规范生成

Agent 能调用外部工具,靠的是@Tool注解。你在某个类的方法上打@Tool,然后把这个类的实例传给AiServices.toolSpecifications()或者通过tools()注册,框架就会扫描这些方法,把每个方法转换成一个"函数规范"(Function Specification)。

这个转换过程包含几个要素:函数名(默认取方法名,可以自定义)、函数描述(取@Tool的 value,这个描述极其重要,模型靠它判断什么时候该调用这个函数)、参数 schema(根据方法参数的类型和@P注解生成 JSON Schema)。生成好的函数规范会被序列化成一段结构化文本,塞进发给模型的请求里。

注意:函数描述写得好不好,直接决定模型能不能正确调用你的工具。我见过太多人写@Tool("查询")这种模糊描述,结果模型要么不调用,要么乱调用。描述里应该写清楚"这个函数做什么、什么时候用、参数是什么含义",越具体越好。

3.3 大模型返回 tool_calls 后的回调机制

这是整个链路里最核心的一环。当你发起一次请求,模型返回的响应可能有两种:一种是普通文本,直接返回给用户;另一种是包含tool_calls的结构化响应,表示"我需要调用某个函数,参数是这些"。框架拿到tool_calls后,会做这么几件事:

首先,根据函数名找到对应的ToolSpecification,进而找到实际要执行的方法。然后,把模型给的参数(通常是 JSON)反序列化成方法参数类型。接着,通过反射调用这个方法,拿到执行结果。最后,把结果包装成ToolExecutionResultMessage,追加到消息历史里,再次发起模型请求。

这个"再次发起请求"就是所谓的回调循环。模型看到工具执行结果后,可能继续要求调用别的工具,也可能给出最终答案。框架会一直循环,直到模型不再要求调用工具,或者达到最大迭代次数(防止死循环)。理解这个循环是理解 Agent 的关键——Agent 不是一次模型调用,而是一个"模型思考-工具执行-模型再思考"的往复过程。

3.4 多轮工具调用的循环控制与终止条件

循环控制看起来简单,实际有不少坑。第一个坑是最大迭代次数。如果不设上限,模型可能陷入"调用工具-结果不满意-再调用"的死循环,烧钱又耗时。LangChain4j 允许你配置这个上限,默认值一般够用,但复杂场景要调大。

第二个坑是工具执行异常的处理。如果工具方法抛异常了怎么办?直接让整个 Agent 失败,还是把异常信息作为工具结果返回给模型,让模型自己决定下一步?后者通常更优雅——模型看到"这个工具报错了",可能会换个工具或者换个参数重试。框架一般会把异常包装成错误消息回填。

第三个坑是并行工具调用。有些模型一次返回多个tool_calls,理论上可以并行执行。但并行执行涉及线程安全和结果顺序问题,实现起来要小心。LangChain4j 在这块的处理策略需要根据版本确认,早期版本可能是串行执行,后续版本才支持并行。

4. 实操:从零搭一个带工具回调的声明式 Agent

4.1 环境准备与依赖配置

先明确依赖。核心是langchain4j主包,加上你选的具体模型集成包(比如对接某家模型服务的包),再加一个日志实现。Maven 里大概是这样:

<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> <version>0.35.0</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-open-ai</artifactId> <version>0.35.0</version> </dependency>

版本号要统一,混用不同版本容易出现 API 不兼容。我踩过一次坑:主包和集成包版本差了一个小版本,结果AiServices的某个方法签名对不上,编译直接报错。所以建议用 BOM 统一管理版本。

4.2 定义工具类与 @Tool 注解的写法

工具类就是一个普通 Java 类,方法上打@Tool:

public class CalculatorTools { @Tool("计算两个整数的和,当用户需要做加法运算时调用") public int add(@P("第一个加数") int a, @P("第二个加数") int b) { return a + b; } @Tool("计算两个整数的乘积,当用户需要做乘法运算时调用") public int multiply(@P("第一个乘数") int a, @P("第二个乘数") int b) { return a * b; } }

注意@P注解里的描述,它会进入参数的 JSON Schema,帮助模型理解每个参数的含义。参数类型尽量用基本类型或简单对象,复杂嵌套对象虽然支持,但模型生成正确 JSON 的难度会上升。

4.3 声明 Agent 接口并绑定工具

interface MathAssistant { @SystemMessage("你是一个数学助手,可以调用工具完成计算") String chat(String userMessage); } MathAssistant assistant = AiServices.builder(MathAssistant.class) .chatLanguageModel(model) .tools(new CalculatorTools()) .build(); String answer = assistant.chat("帮我算一下 23 加 45 等于多少");

调用chat时,框架会把用户问题、系统消息、两个工具的函数规范一起发给模型。模型判断需要调用add,返回tool_calls,框架执行add(23, 45)得到 68,回填后再请求模型,模型给出最终答案"23 加 45 等于 68"。

4.4 参数计算与工具选择的实测记录

我实测了几个场景,记录一下。第一个场景:问"23 加 45",模型正确调用了add,参数 23 和 45 都对。第二个场景:问"23 加 45 再乘以 2",模型先调add得到 68,再调multiply(68, 2)得到 136,两轮工具调用,循环控制正常。第三个场景:问"今天天气怎么样",模型没有调用任何工具,直接回复"我无法获取天气信息"——因为工具列表里没有天气相关函数,模型识别到了。

这里有个观察:模型的工具选择能力,和函数描述的清晰度强相关。我把add的描述改成模糊的"计算",模型在"23 加 45"时反而犹豫了,有时会去调multiply。改回明确描述后恢复正常。所以别在描述上偷懒。

5. 常见问题与排查技巧实录

5.1 工具不被调用或调用错误的排查思路

工具不被调用,先查三件事:工具有没有正确注册(tools()传了吗)、函数描述够不够清晰(模型能不能看懂)、模型本身支不支持函数调用(有些小模型或旧模型不支持 tool_calls)。这三条排查下来,八成问题能定位。

调用错误(比如该调 A 却调了 B),通常是描述重叠导致的。两个工具的功能描述如果太像,模型会混淆。解决办法是把描述写得更区分化,明确各自的适用边界。另外,参数类型不匹配也会导致调用失败,比如模型生成了字符串但你声明的是 int,框架反序列化时会报错。

5.2 循环不终止与最大迭代次数配置

循环不终止的典型表现是:Agent 一直调用工具,迟迟不给最终答案。原因可能是工具返回的结果模型不满意,反复重试;也可能是工具描述有歧义,模型在几个工具间来回横跳。这时候最大迭代次数就是保命符。

MathAssistant assistant = AiServices.builder(MathAssistant.class) .chatLanguageModel(model) .tools(new CalculatorTools()) .maxSequentialToolsInvocations(5) .build();

设成 5 意味着最多连续调用 5 次工具,超过就强制终止。这个值要根据业务复杂度调,简单场景 3 就够,复杂编排可以到 10。设太小会导致正常的多步任务被截断,设太大则失去保护意义。

5.3 工具执行异常的优雅处理

工具方法抛异常时,默认行为可能让整个 Agent 失败。更好的做法是在工具方法内部捕获异常,返回一个描述错误的结果字符串,让模型看到错误后自行决策。比如:

@Tool("查询订单状态") public String queryOrder(@P("订单号") String orderId) { try { return orderService.query(orderId); } catch (Exception e) { return "查询失败:" + e.getMessage() + ",请确认订单号是否正确"; } }

这样模型收到"查询失败"的结果后,可能会提示用户检查订单号,而不是整个流程崩掉。

5.4 常见问题速查表

问题现象可能原因排查方向
工具完全不被调用未注册 / 模型不支持 / 描述缺失检查 tools() 配置和模型能力
调用了错误的工具描述重叠 / 参数歧义细化函数描述,区分适用场景
循环不终止结果不满意 / 描述歧义设置最大迭代次数,优化描述
参数反序列化失败类型不匹配 / JSON 格式错检查参数类型,简化参数结构
返回结果解析失败返回类型复杂 / 格式不符用简单返回类型,或自定义解析器

6. 几个只有踩过才知道的经验

第一个经验:函数描述是 Agent 的灵魂,不是附属品。很多人把@Tool的描述当注释随便写,结果模型调用准确率惨不忍睹。我的做法是把描述当成给一个新人看的说明书来写——他要能只看描述就知道这个函数干什么、什么时候用、参数怎么填。

第二个经验:工具粒度要适中。太细会导致模型需要调用很多次才能完成任务,太粗则模型难以正确填参数。一个工具最好对应一个明确的业务动作,参数控制在 3 个以内。

第三个经验:调试时打开请求日志。LangChain4j 支持配置日志级别,把发给模型的完整请求和收到的响应打出来,你能清楚看到函数规范长什么样、模型返回的 tool_calls 是什么。这是排查问题最快的手段,没有之一。

第四个经验:别迷信声明式的全自动。声明式 Agent 适合工具调用逻辑相对固定的场景。如果你的 Agent 需要复杂的条件分支、动态工具选择、多 Agent 协作,纯声明式可能不够用,得结合命令式的ChatLanguageModel手动编排。声明式和命令式不是对立的,混用才是常态。

这套架构我用了大半年,从最初的"照着文档抄"到现在的"知道每一步为什么这么设计",中间踩的坑基本都写在这了。动态代理那层其实不难,难的是理解函数回调循环里模型和框架的分工——模型负责决策调什么,框架负责执行和回填,两者配合得好,Agent 就稳;配合不好,就是无尽的循环和错误调用。把函数描述写清楚、把迭代上限设合理、把异常处理做优雅,这三点做到,大部分问题都能避免。

相关新闻

基于RapidOCR的Python OCR源码:输出结构化HTML,支持批量处理

基于RapidOCR的Python OCR源码:输出结构化HTML,支持批量处理

简介&#xff1a;这是一套基于 rapidOCR 改造的完整 OCR 图片识别源码项目&#xff0c;面向需要将图片文字识别能力集成到自有系统中的开发者与 Python 学习者。项目已实现 OCR 识别并返回带标记的 HTML 结构&#xff0c;内置 /engine/ocr 接口&#xff0c;可接收 HTTP 链接、本…

2026/10/20 7:07:52 阅读更多 →
C# Gerber 查看器源码解析:从 PCB 文件读入到渲染的完整实现

C# Gerber 查看器源码解析:从 PCB 文件读入到渲染的完整实现

简介&#xff1a;这份资源是一套基于C#实现的Gerber查看器源码&#xff0c;面向电子工程师、PCB设计人员以及希望学习文本解析与图形渲染的C#开发者&#xff0c;用于解决PCB制造中Gerber图形导入与查看的问题。压缩包共35个文件&#xff0c;约76KB&#xff0c;以cs源码文件为主…

2026/10/20 7:07:52 阅读更多 →
扩散语言模型:资本重注下的技术新路线,它与自回归有何本质差异?

扩散语言模型:资本重注下的技术新路线,它与自回归有何本质差异?

最近市场上一笔流向“扩散语言模型”方向的融资&#xff0c;金额到了数亿元量级&#xff0c;投资方里既有知名企业家&#xff0c;也有头部科技企业。关注AI的人第一反应多半是&#xff1a;扩散语言模型是什么&#xff1f;我们平时用的聊天、写作、翻译模型&#xff0c;几乎清一…

2026/10/20 7:07:52 阅读更多 →

最新新闻

打印数组最大数:从循环遍历到并行与流式处理的完整指南

打印数组最大数:从循环遍历到并行与流式处理的完整指南

别小看“打印数组最大数”这五个字。我见过太多工作三五年的开发者&#xff0c;写这段逻辑时栽在没人提醒的细节上&#xff1a;数组为空怎么办&#xff1f;全是负数怎么办&#xff1f;元素是浮点数带NaN怎么办&#xff1f;数据量到千万级怎么办&#xff1f;这段代码几乎是所有编…

2026/10/20 8:02:22 阅读更多 →
AI写PLC代码靠不靠谱?三个月实战经验与踩坑总结

AI写PLC代码靠不靠谱?三个月实战经验与踩坑总结

做PLC这行十几年了&#xff0c;最近三个月工作室群里聊得最多的就是AI写程序。一开始我是拒绝的&#xff0c;毕竟现场出问题是要跳闸停机的&#xff0c;谁敢拿一个“聊天机器人”生成的代码往设备上怼&#xff1f;但架不住项目催得紧&#xff0c;某天夜里实在改不动了&#xff…

2026/10/20 8:02:22 阅读更多 →
用DeepSeek降论文AI率:从检测原理到改写实操

用DeepSeek降论文AI率:从检测原理到改写实操

论文AI率飚红怎么办&#xff1f;先别急着满世界找“降痕神器”&#xff0c;我在这篇文章里把DeepSeek知网报告这套搭配的真实用法讲明白。很多同学遇到红色数值的第一反应是焦虑&#xff0c;第二个反应是找提示词“洗稿”&#xff0c;但这路子走偏了。真正能帮你把论文救回来的…

2026/10/20 8:02:22 阅读更多 →
CANUAV C源码解析:UAVCAN协议栈实现与嵌入式避坑指南

CANUAV C源码解析:UAVCAN协议栈实现与嵌入式避坑指南

简介&#xff1a;CANUAV是一套基于C语言实现的UAVCAN协议栈源码&#xff0c;面向在STM32F407平台上开发无人机及自动化设备通信节点的嵌入式工程师。它依托轻量级Canard库完成UAVCAN核心功能&#xff0c;将传感器数据、控制指令、状态报告等消息映射到CAN帧&#xff0c;并实现心…

2026/10/20 8:02:22 阅读更多 →
MIMO通信系统Matlab仿真:Alamouti编码与BER曲线实现

MIMO通信系统Matlab仿真:Alamouti编码与BER曲线实现

简介&#xff1a;这是一套面向通信工程学生与无线通信初学者的MIMO通信系统Matlab仿真实现&#xff0c;基于多天线收发、空间复用/分集与波束赋形构建立可运行链路&#xff0c;帮助理解信道容量提升、抗衰落机制及不同参数对系统性能的影响。压缩包共3个文件&#xff0c;以2个m…

2026/10/20 8:02:22 阅读更多 →
分时电价省钱实操:把家电搬到谷段,一年省几百元

分时电价省钱实操:把家电搬到谷段,一年省几百元

“早上八点的电费涨到一块二了&#xff0c;家里的洗衣机还在嗡嗡转。”这句话如今已经成了我家的自嘲梗。说真的&#xff0c;分时电价这玩意儿&#xff0c;第一次认真刷电费账单的人都会愣一下&#xff1a;一天之内电价居然能上蹿下跳好几回&#xff0c;早上出门前和深夜睡觉前…

2026/10/20 8:01:21 阅读更多 →

日新闻

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →
ESP32舵机控制指南:原理、多路驱动与避坑实战

ESP32舵机控制指南:原理、多路驱动与避坑实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →
位移法实现一阶低通滤波:单片机ADC采样降噪实战

位移法实现一阶低通滤波:单片机ADC采样降噪实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型&#xff08;GMM&#xff09;的语音识别系统&#xff0c;很多人第一反应就是一堆数学公式&#xff0c;觉得门槛很高。但如果你真的在MATLAB里把它落地跑通&#xff0c;会发现核心其实就是三件事&#xff1a;把声音变成特征向量&#xff0c;用GMM给每个类别…

2026/10/19 10:02:04 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人&#xff0c;但落到具体操作上&#xff0c;无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程&#xff1a;对图像做反白、改调色板、彩色转灰度&#xff0c;最后把像素数据导出成txt文件。单看每一…

2026/10/19 17:32:41 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词&#xff0c;你会发现一个很有意思的现象&#xff1a;每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型&#xff0c;有人指的是运动控制系统&#xff0c;还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/20 2:50:50 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/19 2:54:50 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/19 9:10:22 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/18 12:16:16 阅读更多 →