news 2026/10/17 3:08:42

HttpAsyncClient 处理 Chunked 分块传输编码的实战指南与踩坑复盘

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
HttpAsyncClient 处理 Chunked 分块传输编码的实战指南与踩坑复盘

前阵子联调一个数据同步接口,对方服务端是边算边返回,响应头里没有 Content-Length,只有Transfer-Encoding: chunked。用 HttpAsyncClient 发异步请求,第一眼看上去返回的 HttpEntity 很普通,但真要正确地把数据读完、把连接还给连接池,里面还是有不少门道。我前前后后踩了几个坑,排查过程也绕了几圈,干脆把关于分块传输编码(Chunked Transfer Encoding)响应处理的经验完整写下来,给同样被这类响应"教育"过的同学一个参考。

这篇文章适合两类人:一个是刚接触 Apache HttpAsyncClient、听说过 chunked 但不知道怎么处理流式响应的新手;另一个是已经在用异步 HTTP 客户端,但遇到连接池耗尽、响应读不全、回调线程卡死等诡异问题的开发者。我会从协议本身讲起,把 HttpAsyncClient 内部做了什么、我们还需要做什么、以及最容易出问题的几个环节全部拆开说清楚。

1. 分块传输编码到底在解决什么问题:从一条"不知道多长"的响应说起

1.1 响应体长度不明时,HTTP/1.1 给出的答案

先回到最基础的问题:HTTP 响应里,数据到哪里算结束?最常见的方式是Content-Length告诉客户端这具响应体有多少字节,客户端读够这个数就停。但有一种情况服务器很尴尬——它根本不知道整体要发多少字节。

典型场景就是服务端动态生成内容,比如:

  • 查询结果要边查数据库边拼 JSON,拼到一半已经可以开始发了;
  • 视频转码服务的进度通知,每出几条进度就要推送一次;
  • 代理网关收到上游慢响应时,自己手里只有流,没有总长度。

这种时候如果死等Content-Length,客户端得等到服务端把整个响应体全部生成完才能拿到第一个字节,体验极差。于是 HTTP/1.1 规定了第二种长度声明方式:Transfer-Encoding: chunked,也就是分块传输编码。

它和Content-Length是互斥的,RFC 7230 里写得很明白:响应里如果带了Transfer-Encoding: chunked,就不能再带Content-Length。这也是很多排查场景里最直接的判断信号——没有 Content-Length 但有 Transfer-Encoding,那 body 就是 chunked。

1.2 分块编码在 TCP 报文层面长什么样

很多人对 chunked 的理解停留在"听说过"的层面,实际抓包看一眼就全明白了。一个 chunked 响应体是这样的:

HTTP/1.1 200 OK Content-Type: application/json Transfer-Encoding: chunked 1f {"orderId":"1001","items":[ {"sku":"A001","count":2} ] 0

注意那几个看似奇怪的换行,里面每一块格式是:

  • 十六进制数字 + CRLF:表示这一块数据的字节长度;
  • 紧接着是这么多个字节的原始数据 + CRLF;
  • 最后是长度0+ CRLF + CRLF,表示整个 body 结束。

我习惯用一个类比来记:这就好比快递公司不知道这一箱货总共有多重,但可以一箱一箱先发走,每个箱子上贴一个标签写清楚"这箱有多重",最后一箱贴个 0 告诉收货人"发货结束"。HTTP 报文的接收方只要按标签逐个拆箱就行,不需要预先知道总重量。

对应用层来说,chunked 并不改变业务数据本身,它只是 HTTP/1.1 在传输层玩的一个"包装"手法。真正的难点是:作为客户端,我们怎么把这一箱一箱的数据高效地取出来,并且别把连接搞坏。

2. HttpAsyncClient 把"拆块"的脏活提前干完了:解码机制与无感知数据通路

2.1 你以为要手写 chunk 解析,其实大可不必

和很多人第一反应不同,HttpAsyncClient 处理 chunked 响应基本是透明的。也就是说,你拿到HttpResponse.getEntity()之后,用entity.getContent()得到的输入流,已经是把 chunk 边界和长度前缀全部剥掉之后的"纯业务数据流"。

这是因为 Apache HttpClient 系列(包括异步版)在协议层内置了 chunked 解码器。响应数据从 TCP 缓冲区被读出来之后,会经过一层 ChunkedInputStream 的解码包装,这层解码把十六进制长度行、CRLF 分隔符、结束块全部消费掉。你的读取代码根本不需要关心哪几个字节属于哪个 chunk,只需要像读普通流一样一直读到-1就算读完了。

我见过有人拿原始 socket 自己收 HTTP 响应,然后盯着 chunk 长度行写状态机,最后累得半死。用 HttpAsyncClient 的话,这个状态机是现成的,不需要重复造轮子。

2.2 数据通路:从网络事件到业务输入流

要理解后面几个坑,最好先对 HttpAsyncClient 的内部数据通路有个大致印象。它底层是 NIO 事件驱动的,默认使用 IOReactor 线程组处理连接上的读写事件。

当服务端把一块 chunk 数据发过来时,IOReactor 线程从 Channel 读数据,经过协议解析器识别出当前是 chunk 数据段,再通过解码逻辑还原成连续的字节流。然后这些字节被交给当前请求关联的 consumer。HttpAsyncClient 默认的 consumer 会把这些字节累积到一个缓冲里,最后在响应完成后包装成可读取的HttpEntity。

这就带来一个重要推论:**IOReactor 线程一直在参与数据的搬运。**如果你在业务线程里慢慢读输入流,只要连接还开着,reactor 线程就会持续关注这个连接。如果你处理得太慢,缓冲区可能会被动扩大;如果你在 reactor 线程里做耗时操作,那整个事件循环都会被堵住,其他请求全部陪跑。这个点后面踩坑章节我还会详细说。

2.3 怎么确认当前响应确实是 chunked

写代码时不要凭感觉判断,直接打日志最靠谱。常用的判断方式有三个:

boolean chunked = entity.isChunked(); System.out.println("isChunked = " + chunked); // 更直接一点,看原始响应头 Header transferHeader = response.getFirstHeader("Transfer-Encoding"); System.out.println("Transfer-Encoding = " + transferHeader);

需要提醒的是,entity.isChunked()的返回值和你读取流时实际遇到的行为可能不完全一致。原因是这个值来自响应头,但经过代理或中间网络设备时,头部可能被改写。比如源站返回 chunked,中间经过一个网关被缓存成完整响应,带上了 Content-Length,这时候isChunked()就是 false。反过来,源站给出 Content-Length,但传输过程被某些异常情况打断,连接关闭时 body 并没有读满,这种就不是 chunked 但读起来也读不到 -1。所以判断响应类型只是第一步,真正可靠的还是"读数据时根据流的实际行为做错误处理"。

2.4 一个容易搞混的概念:不是所有"持续输出的响应"都是 chunked

有些同学把 chunked 和长连接、流式响应混为一谈。其实 chunked 解决的是"长度未知"的问题,而长连接解决的是"连接复用"的问题。一个连接可以持续复用、同时每个响应都有明确的长度声明,这两件事是独立的。

还有 SSE(Server-Sent Events)这类场景,服务端通过text/event-stream持续输出事件。SSE 响应通常也会用 chunked 传输,因为服务端同样不知道总共要输出多少事件。但反过来说,chunked 并不等于 SSE,它也可能就是一份普通的 JSON,只是服务端懒得算长度或者没法提前知道长度。搞清楚这点,调接口时才不会看到 chunked 就以为对方是个流式接口。

3. 实战:三种方式把分块响应一块一块读出来

理论说完,直接上代码。我用的是 Apache HttpAsyncClient(HttpClient 4.x 异步包),连接池和请求配置一次性配好,然后分三种姿势读取分块响应。

3.1 基础版:Future + getContent() 流式消费

最推荐的方式,用Future<HttpResponse>拿响应,然后像读普通文件一样读取流:

RequestConfig config = RequestConfig.custom() .setConnectTimeout(5000) .setConnectionRequestTimeout(5000) .setSocketTimeout(30000) .build(); PoolingAsyncClientConnectionManager manager = new PoolingAsyncClientConnectionManager(); manager.setMaxTotal(200); manager.setDefaultMaxPerRoute(100); CloseableHttpAsyncClient client = HttpAsyncClients.custom() .setDefaultRequestConfig(config) .setConnectionManager(manager) .build(); client.start(); try { HttpGet request = new HttpGet("http://example.com/api/stream"); Future<HttpResponse> future = client.execute(request, null); // 注意:get 超时之后别直接放弃,后面我会讲怎么收尾 HttpResponse response = future.get(30, TimeUnit.SECONDS); HttpEntity entity = response.getEntity(); System.out.println("chunked? " + entity.isChunked()); System.out.println("Content-Length? " + entity.getContentLength()); try (InputStream in = entity.getContent()) { byte[] buf = new byte[8192]; int n; long total = 0; while ((n = in.read(buf)) != -1) { total += n; // 这里拿到的就是解码后的业务数据,没有 chunk 长度前缀 // 按自己的格式解析,比如写入文件、逐行解析 JSON 片段等 } System.out.println("read finished, total=" + total); } } finally { client.close(); }

这段代码有几个细节值得说。

第一,entity.getContent()返回的 InputStream 实现类,在 HttpClient 4.x 里通常是ContentInputStream,内部封装了 chunked 解码。你读出来的字节流已经是干净的业务数据,不需要自己拼 chunk。

第二,try-with-resources一定要用。分块响应如果不读到-1就关闭流,底层会把这个连接标记为"响应体未完整消费",连接池就无法把这个连接归还复用。很多连接池耗尽的问题就是从这里来的。

第三,流的读取要放在业务线程,不要放在future.get()之后的调用点上就掉以轻心——如果这个调用点本身就在某个共享线程里,还是会影响别人。

3.2 回调版:FutureCallback 与线程模型的注意事项

有的场景需要异步回调,比如发完请求不等结果,后续逻辑在回调里触发。HttpAsyncClient 支持FutureCallback<HttpResponse>:

HttpGet request = new HttpGet("http://example.com/api/stream"); client.execute(request, new FutureCallback<HttpResponse>() { @Override public void completed(HttpResponse response) { HttpEntity entity = response.getEntity(); if (entity == null) { return; } // 注意:completed 跑在 IOReactor 线程上, // 不能在这里直接读完整条流或做耗时操作 asyncReadHandler.submit(() -> { try (InputStream in = entity.getContent()) { byte[] buf = new byte[4096]; int n; while ((n = in.read(buf)) != -1) { // 业务处理 } } catch (IOException e) { // 异常处理 } }); } @Override public void failed(Exception e) { // 连接失败、超时、读取中断等都会走这里 } @Override public void cancelled() { // 主动取消时会触发 } });

这里最容易被忽略的是线程问题。completed方法的执行线程就是 NIO reactor 线程,它是所有异步连接的公共调度线程。如果你在completed里直接EntityUtils.toString(entity),一旦这个响应体有几个 MB,IOReactor 线程就被你占住了,整个客户端其他请求的读写全都会受影响。

正确的做法是:在completed里只做"把流交给业务线程池"这件事,然后业务线程池去消费流。我习惯用一个专门的 reader 线程池来读流,避免把 reactor 线程堵住。

3.3 进阶版:直接用 AsyncByteBufferConsumer 逐块消费

还有一种更贴近底层的方式:自己实现 consumer,在每一块数据到达时做处理。这样甚至都不需要等到整个响应完成再读流,可以做到"来一块处理一块"。

client.execute(request, new AsyncByteBufferConsumer() { @Override protected void onByteReceived(ByteBuffer buf, IOControl ioctrl) { // 这一块数据已经到达,buf 里是解码后的部分数据 // 注意:同样跑在 reactor 线程,不能做复杂逻辑 // 常见做法:拷贝进队列,交给其他线程 byte[] bytes = new byte[buf.remaining()]; buf.get(bytes); queue.offer(bytes); } @Override protected void onResponseReceived(HttpResponse response) { // 响应头到达时就可以拿到状态码和头信息 } }, new FutureCallback<HttpResponse>() { @Override public void completed(HttpResponse response) { // 响应完成的回调 } @Override public void failed(Exception e) { } @Override public void cancelled() { } });

AsyncByteBufferConsumer的好处是内存更可控。默认消费方式把整个响应体累积到一块,遇到超大分块响应容易把内存撑爆;自己实现 consumer 则可以在每一块字节到达时立刻转发出去,不需要在客户端这边攒完整份数据。代价是代码变复杂,而且边界条件很多,比如连接中断时怎么通知下游、队列积压时要不要背压。

我的建议是:默认场景用 3.1 的流式读取就够了,追求极致的流式处理再上 consumer。

4. 我把分块响应读"坏"过的几个姿势:踩坑复盘与根因排查

这部分是重点,我把真实复现过的几个问题按排查链路完整写下来,照着这个思路可以少走弯路。

4.1 问题一:连接池打满,日志出现 Connection pool is exhausted

先说现象:某个调第三方数据接口的服务,平时流量不大,但每次一触发批量任务,连接池很快就耗尽,日志不断出现Connection pool is exhausted。

我一开始怀疑是连接池配置太小,调大了 MaxTotal 和 MaxPerRoute,结果只是把报错推迟了几分钟。后来发现一个规律:连接耗尽总是发生在读某个分块大响应之后。于是我去查那些请求到底有没有把响应体读完。

排查链路是这样的:

  • 第一步:在读取结束后打日志,对比entity.getContentLength()和实际读到的字节数。如果getContentLength()返回 -1,说明就是 chunked,这时判断依据只能是"是否读到了 -1"。
  • 第二步:加钩子看连接释放情况。Apache HttpClient 的连接池在响应体完整消费后才会把连接标记为可复用,如果流没有读完就 close,连接会被丢弃而不是归还。
  • 第三步:在读取代码里找到了元凶——异常分支里只 catch 了业务异常,但忘记关闭 InputStream。一旦处理过程中抛了业务异常,流就永远没人读了,连接也就从池子里"消失"了。

修复方案并不复杂:确保无论正常还是异常,都把流读到底或者至少正确关闭。最好用try-with-resources,然后只在确认读到-1的情况下才认为连接可以归还;如果业务不需要剩余数据,也要显式EntityUtils.consume(entity)让客户端把残余字节读完并归还连接。

这条排查链路最大的启发是:连接池耗尽不一定就是池子太小,很多时候是"连接根本没回来"。

4.2 问题二:读取分块响应时 UI 假死,排查发现是 reactor 线程被堵

另一个典型案例:一个桌面工具程序,点按钮后去请求大数据接口,界面卡死。最初怀疑是网络慢,后来发现只要响应是 chunked 且体积稍大,界面必卡。

排查时我先抓了线程栈,发现卡死的线程并不是 UI 线程,而是某个名为 IOReactor 的线程。栈顶正好停在FutureCallback.completed方法里,再往下是EntityUtils.toString。这下真相大白:我在回调里直接做了完整读取,把整个 reactor 线程占住了。

Apache HttpAsyncClient 底层是 NIO 事件循环,所有连接的事件都由少数几个 reactor 线程处理。你在回调里读流的时间越长,其他连接的事件处理就被推迟得越久。如果数据量大或者网络慢,整个连接池内的请求都会排队等待,表现就是客户端整体"卡死"。

修复方式就是 3.2 里写的:回调只负责把entity.getContent()的结果提交给业务线程池,真正读流和解析全在业务线程里做。修完这根线程就松快了,UI 不再卡,其他异步请求也恢复顺畅。

4.3 问题三:服务端在 chunk 之间断开,到底算成功还是失败?

还有一个让人纠结的场景:服务端返回了部分数据,然后在下一个 chunk 还没发完时 TCP 连接断了。我的读取循环读到一半抛了IOException,前面的数据已经交给业务逻辑处理了。

这里有个经验性的判断原则:流式接口必须以"流结束标记"为成功标准,不能以"收到响应头"为标准。对 chunked 来说,正常的成功结束一定是读到那个长度为 0 的结束块,也就是你的read()返回-1。如果中途抛异常,不管之前已经处理了多少块,整个事务都应该按失败处理。已经处理过的部分数据如果是有业务语义的,要做幂等补偿,不能因为"前面读得很顺利"就认为成功。

我们后来在业务层约定:每条数据本身带一个 end 标记,客户端读到 end 标记才算成功;如果抛异常但没读到 end,就把已积累的数据丢弃并标记任务失败重跑。这样网络抖动、服务端崩溃都能被正确识别。

5. 超时、连接池与资源回收:分块场景下的配套设置才是真正的胜负手

5.1 SocketTimeout 与"两个 chunk 之间等太久"

很多人把 SocketTimeout 理解成"整条响应必须在 N 秒内读完",其实不对。SocketTimeout 在 TCP 层面表示的是"两个数据包到达之间的最大空闲时间",也就是读操作阻塞的最大时长。

这在分块响应里非常关键。服务端如果边算边发,块与块之间可能停顿很久。比如一个查询要跑 40 秒,中间每 5 秒发一个进度块,但最后一块还在算,那么 socket 超时时间如果小于 40 秒,客户端会在等下一块数据时超时,哪怕整个响应其实还在正常生成中。

我踩过这个坑:把 SocketTimeout 配成 10 秒,调一个慢查询接口,结果每 11 秒稳定超时一次。后来把 SocketTimeout 调到 60 秒才稳定。所以分块场景下的超时时间要按"服务端最大块间隔"来配,而不是按"整体响应时长"。

配置代码:

RequestConfig config = RequestConfig.custom() .setConnectTimeout(3000) .setConnectionRequestTimeout(5000) .setSocketTimeout(60000) // 重点:按块间隔算 .build();

5.2 连接复用与 EntityUtils.consume 的正确姿势

分块响应处理完以后,连接能不能还给连接池复用,完全取决于流是否被完整消费。如果你只关心响应前 1KB 数据,后面的一堆 chunk 不想读,直接关流虽然不会报错,但连接无法复用,等于白白浪费一个连接。

这种情况下最标准的做法是显式消费掉剩余内容:

HttpEntity entity = response.getEntity(); // 读了一部分,后面不重要了 if (entity != null) { EntityUtils.consume(entity); // 内部会把剩余数据读完并释放连接 }

EntityUtils.consume会一直读到流的末尾,确保底层连接状态是干净的。要注意的是,这方法会真实地把剩余字节从网络上读完,如果剩余部分特别大,它会花时间,但这是保证连接可复用的必要代价。

5.3 连接池参数对分块长连接的影响

分块响应天然适合连接复用,因为服务端和客户端都没有长度约束,读完一个响应后连接立刻可以发下一个请求。但如果连接池太小,多个慢流式请求会把连接全占住,后续请求只能排队等ConnectionRequestTimeout,超了就报错。

我建议分块场景下至少关注三个参数:

参数建议理由
MaxTotal100~300留足余量给慢流式响应占用的连接
DefaultMaxPerRoute50~150按实际并发峰值配置
ConnectionRequestTimeout5~10s等待空闲连接的最大时间,别配太长

配完连接池,再配合 4.1 的连接归还检查,基本就不会出现连接池被分块响应拖垮的情况。

5.4 重试策略:分块响应中断后别盲目重试

分块响应读到一半断了,能不能重试?要看请求是否幂等。如果前面读到的数据已经对业务产生了影响,重试可能导致重复处理。如果只是拉取数据、解析失败,重试没问题。

HttpAsyncClient 的重试机制(HttpRequestRetryHandler)默认对幂等请求(GET、HEAD 等)生效,但重试发生在连接异常时,如果响应已经开始传输且断在中间,重试一般会重新发起请求。这种时候要注意接口那边会不会重复计算、重复推送。我通常的做法是给请求加一个全链路 trace id,服务端根据 trace id 做幂等,客户端这边记录已经处理到的偏移量,重试时从偏移量往后继续。

6. 再往前一步:SSE、HTTP/2 与分块响应之外的选择

6.1 SSE 和 chunked:近亲关系

SSE(Server-Sent Events)是服务端主动推送技术,响应类型是text/event-stream。实际抓包你会发现,SSE 响应几乎都会带Transfer-Encoding: chunked,因为服务端也是不知道总长度、边生成边发。用 HttpAsyncClient 拉 SSE 数据流的做法和普通 chunked 完全一样,只要把entity.getContent()拿到的输入流交给一个按行解析的 reader 就行,因为 SSE 的协议格式是data: ...分行,每行一个事件。

但 SSE 有一个额外要求:客户端要长时间保持连接,期间可能一直有数据流动。这时候连接池的占用时间会更长,建议给 SSE 请求单独开一个连接池,避免和普通短请求互相抢连接。

6.2 HTTP/2 下没有 chunked 了,但流式响应依然存在

如果服务端和客户端都支持 HTTP/2,chunked 这个头就不需要了。HTTP/2 用 DATA 帧天然支持流式传输,一个流可以分多个帧慢慢发,接收方按帧读取,不需要在应用层再套一层长度前缀。Apache HttpClient 5.x 的异步版本已经支持 HTTP/2,但需要额外的 h2 依赖和配置。

要不要为了"支持流式"从 HTTP/1.1 切到 HTTP/2?我的建议是:如果现有服务用 chunked 已经跑得挺好,切换价值不大;如果追求多路复用、低延迟,HTTP/2 确实能把多个请求在一个连接上并行处理,减少连接数。但切换意味着服务端、网关、负载均衡都要支持,迁移成本要整体评估。

6.3 什么时候该考虑 WebSocket

还有一种常见"持续性通信"场景是 WebSocket。注意 WebSocket 不是 HTTP 响应的一种形式,它是通过 HTTP 升级握手切换到另一个二进制协议。如果只是想单向接收服务端推送,SSE/分块响应就够用;如果要做双向实时交互,才需要 WebSocket。HttpAsyncClient 本身不处理 WebSocket,需要引入专门的客户端库,协议处理也是另一套体系,别把它和 chunked 混为一谈。

我在实际项目中,判断标准很简单:服务端单方向发数据给我,我用分块响应/SSE;需要我主动发消息且要求低延迟,才上 WebSocket。大多数数据同步、进度通知场景,分块响应都比 WebSocket 轻量得多。

最后再分享一个小经验:处理分块响应时,别只盯着业务代码,一定要把连接归还、线程模型、超时配置三个维度一起检查。我见过太多人把问题归结为"HttpAsyncClient 的 bug",最后发现是流没读完导致连接泄漏、或者在 reactor 线程上做了阻塞操作。先看entity.isChunked(),再确认流的读取方式和线程归属,最后用抓包确认服务端到底是怎么发的,这套组合拳打下来,分块响应基本不会再有意外。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 3:08:37

前后端分离博客系统实战:SpringBoot+Vue+MyBatis全栈部署指南

前后端分离这个词&#xff0c;在个人项目里被说烂了&#xff0c;但真正把这套架构跑通、部署上线、还能在日常使用中顺手维护的博客系统&#xff0c;其实没那么多。我这个项目用的是 SpringBoot Vue MyBatis MySQL 这套组合&#xff0c;前后端完全分离&#xff0c;源码和部署…

作者头像 李华
网站建设 2026/10/17 3:07:53

LogViewer:终端原生日志探针,秒级定位OOM与超时错误

简介&#xff1a;这是一款面向开发、运维及测试人员的轻量级日志实时查看与分析工具——LogViewer Pro&#xff0c;专为高效处理超大体积日志文件设计&#xff0c;实测可稳定加载并流畅浏览46GB级别日志&#xff0c;显著缓解传统文本编辑器卡顿、崩溃等痛点。资源包为551KB的ZI…

作者头像 李华
网站建设 2026/10/17 3:07:21

智能牧场AI监控产犊全流程:从算法到部署的实战解析

凌晨两点半&#xff0c;值班手机突然震动&#xff0c;屏幕上跳出一条告警&#xff1a;3号牛舍B12栏位母牛检测到持续努责行为&#xff0c;胎衣已破&#xff0c;预估30分钟内产出。在以往&#xff0c;这个时间点我可能已经顶着寒风跑了两趟牛舍&#xff0c;襁褓里的犊牛能不能活…

作者头像 李华
网站建设 2026/10/17 3:07:21

Linux多命令合并执行全解:分号、管道、函数,一篇讲透

说实话&#xff0c;我刚接触“Linux将多条指令合并为一条执行”这个玩法时&#xff0c;是有点不以为然的。当时在服务器上维护一套静态资源&#xff0c;每天重复做三件事&#xff1a;备份、压缩、清理。每次都要先敲备份命令&#xff0c;等它跑完再敲压缩&#xff0c;最后再删旧…

作者头像 李华
网站建设 2026/10/17 3:06:35

从反馈真空到AI写作教练:一款英语写作APP的技术实现与成本优化

很多人学英语卡住的地方&#xff0c;不是词汇量不够&#xff0c;而是写完之后“没人管”。上大学那会儿我写英文作文&#xff0c;交上去要等老师好几天才能返回来&#xff0c;批注还多半是几句“注意时态”“表达不地道”这类笼统的话。工作以后写英文邮件&#xff0c;自己读三…

作者头像 李华
网站建设 2026/10/17 3:06:14

双串锂电池主动均衡设计:MP2642GR-Z与ATmega644协同方案

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

作者头像 李华