花了大半个月我把自己的 AI 助手用 Ktor 完整重写了一遍做成一个本地优先Local-First的 Agent并且把代码开源了。项目不大但踩的坑不少模型工具调用格式不兼容、Agents 循环不收敛、本地向量检索的选型、WebSocket 流式输出中断……这篇博客就是把回看时的设计思路、核心实现和避坑经验一次性整理出来。先说清楚它是干什么的一个基于 Ktor 构建的本地优先 AI Agent模型跑在 Ollama 上聊天记录、工具状态、知识库全部留在本地设备支持多轮对话、工具调用、可插拔模型适配和长期记忆。适合三类读者想用 Kotlin/Ktor 写 AI 应用的开发者、想从 0 到 1 搭个人 Agent 但不想依赖云端服务的玩家、以及关注数据隐私、希望把 AI 助手彻底私有化的折腾型用户。1. 项目定位为什么是 Ktor为什么坚持本地优先1.1 技术选型Ktor 对比 FastAPI 与 Spring AI动手之前我其实在 FastAPI、Spring AI 和 Ktor 之间纠结了一周。很多朋友会条件反射地推荐 Python 系方案但我的主力语言是 Kotlin日常服务端也都是 Ktor 写的所以核心诉求是这个框架不仅能跑 Agent还得让我写得顺手。三个方案放到一起看框架语言生态异步模型启动体积AI/Agent 生态适合场景KtorKotlin/JVMKotlin 协程小几十 MB 级弱需要自己对接轻量私有服务、协程密集 IOFastAPIPythonasyncio小强LangChain 等快速原型、AI 应用开发Spring AIJava/KotlinThread/Reactor较大中等官方组件多企业级、重 Spring 体系最终选 Ktor 的原因有三条。第一协程模型和 Agent 场景天然契合。Agent 的核心循环里每一步都是 IO 密集操作调模型、执行工具、再调模型、再执行传统线程模型在这种场景下要么靠线程池堆资源要么写大量异步回调而 Kotlin 协程可以让代码按顺序写同时保持极高的并发吞吐。第二Ktor 内置 WebSocket流式输出直接开箱即用不需要像 Spring 那样额外引入一堆配置。第三Kotlin 的类型系统和编译期检查帮我挡掉了很多低级错误尤其在工具注册这块后面会细说。FastAPI 的 Python 生态确实诱人但对我来说为了 LangChain 放弃类型安全不太值——毕竟 Agent 这层编排逻辑本来就不复杂真正的复杂度在工具集成和状态管理上这恰恰是 JVM 系的强项。1.2 本地优先到底解决了什么本地优先不是买点而是一种明确的技术取舍。我把它拆成三层来说第一层是数据层面。对话记录、工具调用日志、知识库文件全部保存在本地 SQLite 和文件系统里不上传任何第三方服务器。这对处理个人笔记、代码片段、内部文档尤其重要你可以直接对 Agent 说总结一下我本地项目里的 TODO完全不用担心敏感信息跑到云端。第二层是推理层面。模型跑在本地 Ollama 上一次请求不走公网。好处是离线也能用、单次推理没有 token 计费、延迟稳定坏处同样明显——本地模型的能力上限比 GPT/Claude 差一个量级尤其在复杂推理、长上下文理解上。我的策略是复杂任务用 32B 级别的模型简单任务用 8B 模型按需切换而不是一个模型打天下。第三层是控制层面。整个 Agent 的每一条 prompt、每一次工具调用、每一个输出解析逻辑都是我可以直接修改的不存在黑盒行为。对开发者来说这种可控性比效果更好但不可解释重要得多排起错来少走很多弯路。1.3 项目结构模块划分的由来第一次写的时候我把所有代码塞进一个 main.kt几千行下来完全不可维护后来痛定思痛按职责拆成五个模块agent-local ├── ktor-server # HTTP WebSocket 入口、请求路由 ├── agent-core # Agent 循环、对话状态机、prompt 组装 ├── tools # 工具注册表、内置工具实现 ├── memory-store # 会话持久化、向量记忆检索 └── model-adapter # Ollama/OpenAI 兼容接口适配这个划分的核心逻辑是model-adapter负责和模型打交道tools只关心工具定义和执行agent-core负责调度memory-store只管状态ktor-server只做对外暴露。这样换模型、加工具、改记忆策略都互不影响。后来社区有人提 issue 说想接入 OpenAI 兼容的云端模型我只需要在model-adapter里加一个实现类Agent 循环一行不改就能跑这就是模块化带来的最大收益。2. Agent 核心循环与工具注册机制2.1 从调接口到 Agent 循环的认知转变很多人以为 Agent 就是把用户问题发给模型然后把回答返回这是完全错误的。Agent 的本质是一个感知-思考-行动的循环模型看到用户请求决定需要调用某个工具程序执行工具拿到结果再把结果喂回模型模型继续判断是否还需要更多工具直到模型认为答案完整为止。我第一版实现就是简单的一问一答发现模型完全不会用工具后来才明白必须实现这个循环。核心代码大概长这样suspend fun run(conversation: MutableListMessage) { var loopCount 0 while (loopCount MAX_LOOP_COUNT) { val response model.chat( messages conversation, tools toolRegistry.schemas() ) val decision responseParser.parse(response) if (decision.toolCalls.isEmpty()) { conversation Message.Assistant(decision.text) break // 模型没有要调用工具说明回答已经完整结束循环 } for (toolCall in decision.toolCalls) { val result toolRegistry.execute(toolCall) conversation Message.Tool(toolCall.id, result) } loopCount } }这里有三个关键点。第一个是MAX_LOOP_COUNT必须存在否则一个模型反复调用同一个工具就会死循环我见过模型因为天气查询失败后连续 20 次重试同一个工具的惨案。第二个是每次工具执行完必须把结果作为 Tool 消息追加进对话历史模型才能看得到工具的返回。第三个是decision可能同时包含文本和工具调用比如我来查一下天气另外提醒你 3 点开会这种混合输出不能简单地二选一。2.2 工具注册用 Kotlin 类型系统约束输入输出工具调用是 Agent 能力的边界如何安全、便捷地把函数暴露给模型是这个项目的核心。我第一版是手写 JSON Schema 数组传给模型但每加一个工具就要写一长串 schema函数签名改了容易忘更新 schema运行时才报错非常痛苦。后来我写了一套基于注解的注册机制直接在函数上用注解声明工具信息Tool( name get_weather, description 查询指定城市的实时天气, args [city: String 城市名例如 北京] ) suspend fun getWeather(city: String): String { val req WeatherRequest(city) return weatherApi.fetch(req).toJson() }通过 Kotlin 反射读取注解和函数签名自动生成模型需要的 JSON Schemafun Function*.toToolSchema(): JsonObject { // 读取 Tool 注解拿到 name/description/args // 从 kotlin-reflect 获取参数名与类型 // 组装成 OpenAI/Ollama 兼容的 tool schema }这样做的最大好处是类型安全如果函数参数从String改成IntReflection 拿到的类型也会变schema 跟着变不会出现模型按旧 schema 传参导致反序列化失败的情况。另外用suspend修饰工具函数意味着工具内部可以自己去调 HTTP、读写文件、做任何耗时操作而不会阻塞 Agent 的事件循环。这种做法本质上是让编译器帮我保证工具边界的正确性我认为值得所有 JVM 系 Agent 项目借鉴。2.3 内置工具与安全边界项目里我内置了四类工具网络搜索调用本地配置的搜索引擎 API可选返回 top-5 结果摘要文件读取限定在白名单目录内读取文本文件用于帮我看看某个日志里有没有报错这类需求命令执行执行白名单内的只读 shell 命令比如git status、df -h不允许任意命令定时提醒把提醒我 3 点开会这类需求落到本地任务队列安全边界是我在这项目里最刻意的设计。模型不可信工具执行环境必须有约束。文件读取限定目录和文件大小命令执行白名单化网络请求限定域名。如果你自己开发同类工具我的建议是宁可功能少一点也要把安全边界做严格Agent 一旦能执行任意命令AI 幻觉的后果会很严重。3. 本地模型接入与 Agent 记忆设计3.1 Ollama 接入不仅是发个请求本地推理我用的是 Ollama因为它同时支持 OpenAI 兼容接口和原生接口而且运行起来非常省心。我选择了原生的/api/chat接口配合tools字段可以拿到原生的 tool_calls 响应省去一层格式转换suspend fun chat(messages: ListMessage, tools: ListJsonObject): ChatResponse { val client HttpClient { install(ContentNegotiation) { json(Json { ignoreUnknownKeys true }) } } val request ChatRequest( model currentModel, messages messages, tools tools, // 工具列表 stream false // 循环阶段用非流式输出阶段再走流式 ) val response: ChatResponse client.post(http://localhost:11434/api/chat) { contentType(ContentType.Application.Json) setBody(request) }.body() return response }实际使用中我发现几个参数对 Agent 行为影响特别大这里给出一份我调参后的推荐值参考参数推荐值原因temperature0.2 ~ 0.4工具调用场景需要确定性温度太高模型会突发奇想改工具参数top_p0.9配合低温度使用保留一定多样性但不出格num_ctx8192 ~ 16384上下文窗口太小会导致多轮工具调用后模型失忆num_predict2048默认值能满足绝大多数工具调用与回答太长增加等待时间repeat_penalty1.1本地小模型容易重复输出工具参数惩罚值要调模型方面我目前主用qwen3:8b处理中英文混合场景和工具调用格式都很稳遇到复杂代码任务时切到qwen3:32b。已实测llama3.1的 tool calling 格式兼容性稍弱如果你也踩到工具调用格式解析失败建议优先试试 qwen 系模型。3.2 记忆从存文件到向量检索Agent 的记忆我迭代了三版每一个版本都是被真实需求逼出来的。第一版最简单直接存 JSON 文件。每次会话结束把对话数组 dump 到磁盘重启进程时再读回来。缺点是只能恢复历史对话模型记不住用户的长期偏好比如用户说过我住在杭州这种事实换一个会话就忘了。第二版升级成 SQLite 存结构化数据把用户反复提到的关键信息抽出来存一张facts表。但这只是记下来了模型回答时不会主动去查表需要靠 prompt 注入事实列表效果一般。第三版做成了真正的向量记忆。每次对话结束后把对话里抽样出的用户陈述句比如我住在杭州、偏好简洁回答交给一个本地 embedding 模型生成向量存进 SQLite下一次用户新对话时把当前输入也 embedding在库里做 top-k 余弦相似度检索把最相关的事实拼接进系统 prompt。核心检索逻辑不复杂fun cosineSimilarity(a: FloatArray, b: FloatArray): Double { var dot 0.0 var normA 0.0 var normB 0.0 for (i in a.indices) { dot a[i] * b[i] normA a[i] * a[i] normB b[i] * b[i] } return dot / (sqrt(normA) * sqrt(normB)) }需要注意的一点是本地 embedding 模型选型很关键。我用的是nomic-embed-text小巧且不需要额外硬件要求。几百条向量时纯 Kotlin 的线性扫描完全够用毫秒级返回根本不需要上向量数据库。很多人一听到向量检索就想到 Milvus/Chroma/Qdrant但对个人项目来说数据量没到十万条级别之前SQLite 余弦相似度是性价比最高的方案这也符合本地优先的克制原则。3.3 流式输出WebSocket 让交互活起来Agent 可以没有 GUI但不能没有流式输出。如果每次让用户等十几秒才拿到完整回答体验会非常糟糕。Ktor 对 WebSocket 支持非常好我直接在服务端开了一个/ws/chat端点把流式 token 实时推给前端fun Route.websocketChat() { webSocket(/ws/chat) { for (frame in incoming) { if (frame is Frame.Text) { val userMessage parseFrame(frame) val agent createAgentForSession(sessionId) outgoing.send(Frame.Text(jsonOf(type to user_ack))) // 用流式方式跑 Agent 循环token 逐字推给前端 agent.streamRun(userMessage).collect { chunk - outgoing.send(Frame.Text(jsonOf(delta to chunk))) } outgoing.send(Frame.Text(jsonOf(type to done))) } } } }选 WebSocket 而不是 SSE我主要考虑了两点一是 Agent 执行过程中除了模型 token 流还要推送正在运行工具这类状态事件用 WebSocket 的 JSON 消息可以统一承载多种消息类型二是 Ktor 对 WebSocket 支持好两端代码都比 SSE 简洁。前端则是简单的原生 HTML JSWebSocket 连接后按type字段区分消息类型收到delta就追加到打字机区域收到tool_start就显示一个 loading 状态。用起来很像一个本地版的 AI 聊天助手但没有第三方依赖。4. 本地跑起来的踩坑记录与排查技巧4.1 常见问题速查表以下是这一个月里我遇到的最典型的问题整理成速查表问题现象根本原因解决方案模型不调用工具反而直接回答我无法查询天气模型不理解工具 schema或 prompt 没说明工具边界在 system prompt 里加入你可以使用以下工具获取实时信息换 qwen 系模型工具调用格式解析失败返回非法 JSON部分模型对 tool call 的输出不够规范用 Ollama 原生 tool_calls 字段而不是让模型自己写 JSON解析失败时重试一次Agent 陷入死循环反复调用同一个工具工具返回的结果模型没有看懂一直重试设置MAX_LOOP_COUNT 10超限后把当前结果发给模型强制生成最终回答多轮对话后模型失忆忘记之前工具结果num_ctx太小上下文被截断把num_ctx调到 16384并做滑动窗口摘要压缩WebSocket 连接总是中断服务端没有做心跳/超时处理客户端每 30 秒发ping服务端定时发心跳帧多个并发对话时 Ollama 排队严重本地模型一次只能跑一个推理Ktor 端用Semaphore(1)串行化推理请求配合队列优化用户体验4.2 调试三板斧日志、原始报文、可观测端点Agent 调试最大的痛点是你不知道模型到底看到了什么。模型是一个概率引擎同样的 prompt 每次输出可能都不同排查问题时要是只盯着最终回答永远找不到根因。我养成的调试习惯有三条第一条开 DEBUG 日志把每次发给模型的完整 prompt 和模型返回的原始 JSON 都记录到本地文件。很多人只记日志不记 prompt排查工具调用失败时还得靠猜。我这边每个会话都有独立日志文件一个show_prompt命令就能回放整个过程。第二条工具调用返回结果入库前做一次合法性校验。模型经常返回不符合预期格式的参数比如城市名写成北京中国我会在工具注册层做一个参数后处理把常见的脏格式清洗一遍清洗不了的直接返回给模型让它修正而不是让异常抛到上层。第三条专门加了一个/debug/tool/runHTTP 接口可以直接在浏览器里手填参数调用任意已注册工具绕过模型层验证工具本身是否正确。排查问题时先确认工具没问题再去看模型指令能省下大量无效时间。4.3 并发与资源控制的真实经验本地模型的一个显著资源瓶颈是单 GPU 或 CPU 推理只能同时跑一个任务。刚开始我没做并发控制两个用户同时问问题Ollama 直接崩溃。后来在 Agent 层加了信号量private val inferenceSemaphore Semaphore(1) suspend fun T guardedInference(block: suspend () - T): T { return inferenceSemaphore.withPermit { block() } }这听起来有点粗暴但实测下来很稳。更复杂的做法是做请求队列和优先级但对个人项目来说Semaphore(1)加上一个简单的队列已经足够。另一个相关问题是内存num_ctx16384时一个 8B 模型大约要吃 6~8 GB 内存如果你同时跑多个模型内存很容易爆。我的建议是启动时检测可用内存动态选择模型而不是写死在配置文件里。5. 开源发布后的几点体会与建议5.1 开源不只是把仓库设为 Public项目决定开源后我踩的第一个坑就是打包。因为涉及 Kotlin JVM、Ollama 外部依赖和 Web 静态资源第一次发布的压缩包在别人电脑上根本跑不起来。后来花了两天时间写好了 Makefile一条命令完成编译、打包、产物校验、启动 Ollama 模型拉取才解决了复现问题。许可证我选了 Apache 2.0理由是它对商业使用友好、有明确的专利授权条款社区接受度也高。主题是如果你想做开源项目自嗨式开源毫无意义真正重要的是让别人 5 分钟内能跑起来。因此我写了比较完整的 README包括项目截图、功能列表、快速开始、架构说明和常见问题还加了一个CONTRIBUTING.md说明如何贡献新工具。发布一周后收到了两个相当有价值的 issue一个提出了 MCP 协议接入的兼容方案一个提供了企业内网环境的 SDK 代理方案这些直接决定了我的 roadmap 优先级。5.2 如果你也想做一个本地 Agent我的建议根据我这个项目的经验给准备动手的朋友几条直接的建议第一不要一开始就追求全功能 Agent。从一个能调工具的多轮对话开始跑通 Agent 循环和工具注册再逐步加东西。第二优先把安全边界做出来命令执行白名单、文件路径校验、网络域名白名单一个都不能少。第三模型和 Agent 循环解耦抽象出ModelAdapter接口永远不要让具体模型直接进入核心逻辑。第四本地优先的核心竞争力不是模型能力而是数据关系Agent 越了解你在本地的笔记、代码习惯和偏好体验越不可替代这部分需要在记忆上下重功夫。5.3 一点个人体会回看这个项目最大的收获不是写出了多强的 Agent而是搞清楚了 Agent 工程的复杂度到底在哪儿。它不在模型调用而在状态管理、工具安全和上下文控制这些看不见的地方。我在写 Agent 循环时反复调整过很多次终止条件在工具 schema 上废过很多心在流式输出上折腾过 WebSocket 重连最后得到的代码加起来并不算多但每一行都带着实际的取舍痕迹。这也是我最终选择开源的原因——这个领域的复杂度不应该被藏在闭源项目里多一个人看到真实实现就多一个人能在此基础上往前走一步。如果你也在做 Ktor、Kotlin 或本地 Agent 相关项目欢迎去 GitHub 上看原仓库的源码有疑问可以在 issue 里直接聊。希望这篇博客能帮你少踩几个我踩过的坑。
企业数字化 ERP 产品动态
相关推荐
小学生C++算法思维入门:用取余除法解数学题 简介:本资源为2024年信息素养大赛C算法创意实践挑战赛小学组初赛真题解析文档,专为小学阶段编程初学者设计,聚焦C基础语法、运算符优先级、条件与循环结构、布尔逻辑及简单算法建模能力训练,助力学生夯实编程根基、提升竞赛解题思… · 2026/11/3 16:11:25
机器人仿真器怎么选?MuJoCo、Isaac Sim与Gazebo实战对比 1. 三款仿真器的定位差异与选型逻辑做机器人仿真这些年,我陆陆续续把 MuJoCo、Isaac Sim、Gazebo 都折腾过一遍。从最早用 Gazebo 配 ROS 跑机械臂抓取,到后来用 MuJoCo 调足式机器人步态算法,再到用 Isaac Lab 批量跑强化学习策略࿰… · 2026/11/3 16:11:25
外贸ERP与进销存系统集成实战指南 1. 项目背景与核心价值外贸行业的信息化建设一直面临着系统孤岛问题。许多企业在发展过程中,往往先上线了进销存系统管理库存和物流,后来又引入了外贸ERP处理客户订单和财务流程。这两套系统各自为政,导致业务人员需要在不同平台间反复切换、… · 2026/11/3 16:11:25
【线性代数】第六章习题:二次型:注意和特征值、特征向量的联系 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 18:22:02
LTX-2本地部署终极指南:3步解决显存瓶颈,生成高清音视频内容 LTX-2本地部署终极指南:3步解决显存瓶颈,生成高清音视频内容
LTX-2是Lightricks开发的高效联合音频-视觉基础模型,能够生成同步的视频和音频内容,为本地AI视频生成带来了革命性的突破。这个强大的开源模型让每个人都能在个人电脑… · 2026/11/3 18:22:02
飞桨文档布局分析神器:PP-DocLayoutV3 ONNX模型完全部署指南 飞桨文档布局分析神器:PP-DocLayoutV3 ONNX模型完全部署指南 【免费下载链接】PP-DocLayoutV3_onnx 项目地址: https://ai.gitcode.com/paddlepaddle/PP-DocLayoutV3_onnx
在当今数字化时代,文档智能处理已成为企业和开发者的核心需求。飞桨Padd… · 2026/11/3 18:22:02
【线性代数】【第五章】特征值与特征向量习题 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 18:22:02
Aurelio演示模式体验指南:一条--demo命令生成模拟家庭数据,先玩懂全部功能再录入真实资产 【免费下载链接】aurelio-finance Open-source personal finance app with an AI financial advisor: track your net worth, investments, ETFs, cash and debts on your own computer. 项目地址: https://gitcode.com/gh_mirrors/au/aurelio-finance 点击查看 免费… · 2026/11/3 18:22:02
PP-LCNet_x0_25_textline_ori_onnx:轻量级文本方向检测模型的终极优势对比 PP-LCNet_x0_25_textline_ori_onnx:轻量级文本方向检测模型的终极优势对比 【免费下载链接】PP-LCNet_x0_25_textline_ori_onnx 项目地址: https://ai.gitcode.com/paddlepaddle/PP-LCNet_x0_25_textline_ori_onnx
PP-LCNet_x0_25_textline_ori_onnx是基于飞… · 2026/11/3 18:21:01
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/2 13:10:28
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 6:04:24
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 5:52:46
STM32F411RE与MAX14431实现SPI隔离:工业现场抗共模干扰实战 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59
ESP32+大模型AI硬件开发:从Demo到产品的8个工程问题 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59
基于MAX14431BAEE+与PIC18F85J10的SPI隔离方案设计与实践 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/2 13:10:28
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 6:04:24
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 5:52:46