08|韧性:一套接口接三家 API —— LLMProvider

这是《Agent全栈开发实战》的第 8 篇。整个系列以 catbuddy(一个本地优先的 AI 编程助手,约 3.6 万行 TypeScript)为案例,由浅入深拆解 harness 设计。

前面几篇我们把 harness 的「内核」讲完了------心脏、手脚、眼睛、记忆。这篇开始聊韧性 :你的 Agent 跑在真实世界里,外面是三家格式各异的 API、会限流会抖动的网络、会返回空字符串会被截断的模型。这一篇就讲 catbuddy 怎么在这片混乱里活下去------一套接口兜住三家差异、主模型挂了自动切、单次调用的瞬态错误自己愈。看完你能拿去跟同事讲清楚「一个生产级 Agent 的容错到底分几层、每层管什么」。


0. 一个让我换模型换到崩溃的下午

我第一次给 catbuddy 接 DeepSeek 的时候,信心满满------不就是再调一个 API 嘛。

结果一跑就炸。Claude 的 system prompt 是个顶级参数 ,DeepSeek 跟着 OpenAI 走,system 是消息数组里的一条 message ;Claude 的工具结果 role 是 user,DeepSeek 是 tool;Claude 流式响应是 SSE 事件,DeepSeek 是 data: [DONE] 结尾......我每接一家,AgentRunner 里就得多一个 if (provider === 'xxx'),越写越像意大利面。

更糟的还在后面。就算三家都接通了,真实世界还会变着花样捅你刀子:DeepSeek 半夜限流(429),Anthropic 偶尔 529 overloaded,模型时不时回我一个空字符串 ,写长文件写到一半被 max_tokens 截断......任何一个没兜住,跑了 40 轮的会话「啪」一下全没了,用户面前只剩一句 An error occurred

这一篇就是那个下午之后我重新设计的答案。它分三段,从外到内:

  1. 适配层 ------一套 LLMProvider 接口,把三家 API 的差异全焊死在子类里,AgentRunner 完全不知道底层是谁;
  2. 故障转移------主模型挂了,自动顺着备选模型链往下试,AgentRunner 还是完全无感;
  3. 三层重试------单次调用里的瞬态错误(超时 / 空回复 / 截断),每一层各自吞掉自己能处理的,处理不了的往上传。

一句话串起来:先让一家挂了不影响别家,再让别家能顶上,最后让每次调用自己能扛住小风浪。


1. 适配层:一套接口,焊死三家差异

1.1 核心契约:AgentRunner 只认一个接口

整个 harness 里,只有一个地方真正调用 LLM ,那就是 provider.chatStreamWithRetry()。AgentRunner 调它的时候,根本不知道自己问的是 Claude 还是 DeepSeek,更不知道是不是已经故障转移到备选了。

这套抽象的核心是 LLMProvider 抽象基类(providers/base-provider.ts)------它定义统一接口,并把所有 Provider 共享的逻辑 收在基类里(重试、role 交替强制、错误分类);两个子类各自实现真正的 chatStream()

javascript 复制代码
// providers/base-provider.ts
export abstract class LLMProvider {
  abstract readonly name: string

  abstract chat(opts: ChatStreamOpts): Promise<LLMResponse>       // 非流式
  abstract chatStream(opts: ChatStreamOpts): Promise<LLMResponse> // 流式 · 子类必须实现

  // ⭐ 带重试的流式调用 ------ 模板方法,写在基类,子类不用 override
  async chatStreamWithRetry(opts: ChatStreamWithRetryOpts): Promise<LLMResponse> { /* ... */ }

  protected isTransientError(response: LLMResponse): boolean { /* ... */ } // 错误分类
  protected enforceRoleAlternation(messages: LLMMessage[]): LLMMessage[] { /* ... */ } // role 交替
}

这是教科书里的策略模式------Provider 是可替换的「策略」,AgentRunner 是「上下文」。换个说法:基类管「所有家都一样的事」(怎么重试、什么算瞬态错误、收尾不能是 assistant),子类只管「这家独有的脏活」(消息格式、流式怎么拼)。整张图长这样:

注意右下角:OpenAICompatProvider 名字里的 "Compat" 不是凑数------任何遵循 OpenAI chat/completions 协议的服务都走它 :DeepSeek、Ollama、vLLM、OpenRouter、通义、Moonshot......一个类覆盖半个 AI 生态。工厂函数(providers/factory.ts)的路由逻辑因此简单到不讲理:

javascript 复制代码
// providers/factory.ts ------ 只看 apiBase 就知道走哪条路
function makeProvider({ model, apiKey, apiBase }): LLMProvider {
  if (apiBase?.includes('anthropic')) {
    return new AnthropicProvider({ apiKey, apiBase, defaultModel: model })
  }
  return new OpenAICompatProvider({ apiKey, apiBase, defaultModel: model }) // 其余全走兼容层
}

这不是偷懒,是对「OpenAI 兼容 API 已经成了事实标准」这个现实的承认。

1.2 三家到底差在哪:消息格式的三处硬伤

子类干的最重的脏活就是消息格式转换 。上层永远用统一的 LLMMessage(里面工具结果就是老老实实的 role: 'tool'),转成各家 API 认的格式,是适配器的事。三处最容易翻车的差异:

差异点 Anthropic OpenAI 兼容(含 DeepSeek)
system prompt 顶级参数 system: "..." 消息数组里一条 { role: 'system' }
工具结果的 role role: 'user' + tool_result block role: 'tool' + tool_call_id
流式里的工具调用 SDK 事件回调,拼好直接给你 参数被拆成多个 chunk,要自己按 index 拼

第一处和第二处,Anthropic 适配器在构建请求时做提取和包装:system 从消息里 filter 出来塞进顶级参数,role: 'tool' 的消息被改写成 role: 'user' 里的一个 tool_result content block(Claude API 压根不认 role: 'tool')。这些全封装在适配器内部,AgentRunner 一无所知。

第三处最阴险------OpenAI 的流式会把一个工具调用的参数拆成好几个 chunk 发 ,每个 chunk 只带一小截 JSON 字符串。你不能拿到一个 chunk 就 JSON.parse,得按 index 攒齐了再解析:

javascript 复制代码
// providers/openai-compat.ts ------ 按 index 聚合流式 tool_call
const toolCallMap = new Map<number, { id: string; name: string; args: string }>()
for await (const chunk of stream) {
  for (const tc of chunk.choices?.[0]?.delta?.tool_calls ?? []) {
    if (!toolCallMap.has(tc.index)) toolCallMap.set(tc.index, { id: '', name: '', args: '' })
    const entry = toolCallMap.get(tc.index)!
    if (tc.id) entry.id = tc.id
    if (tc.function?.name) entry.name = tc.function.name
    if (tc.function?.arguments) entry.args += tc.function.arguments  // ⭐ 流式拼接
  }
}
// 循环结束后才 safeParseJSON(entry.args) ------ 攒齐再解析

顺带提一个「能力探测」的小心机:虽然格式兼容,但功能不一定兼容。DeepSeek 不支持图片输入,catbuddy 用 apiBase.includes('deepseek.com') 探测出来后,自动把图片降级成一句文字占位([N attached image(s) not sent: this provider only accepts text...]),避免一个莫名其妙的 400。一个类覆盖半个生态的代价,就是得为这些「方言」留几个开关。

1.3 一个调了我一下午的隐蔽坑:DeepSeek 的 reasoning_content 必须往返带着走

这是本节的重头戏,也是我那天真正卡了几个小时的地方。

DeepSeek R1 这类推理模型,会在流式响应里多吐一个 reasoning_content------它的「思考过程」。这不是标准 OpenAI 字段,是 DeepSeek 借 OpenAI 格式塞的私货。捕获它很简单,跟捕获正文 content 一样,多接一个 delta:

javascript 复制代码
// providers/openai-compat.ts · chatStream()
const reasoningDelta = (delta as { reasoning_content?: string }).reasoning_content
if (reasoningDelta) {
  reasoningContent += reasoningDelta
  await opts.onThinkingDelta?.(reasoningDelta)   // 一路流到可折叠的「推理气泡」
}
// ...最后随 LLMResponse 一起返回
return { content, toolCalls, /* ... */ reasoningContent: reasoningContent || undefined }

到这一步都很顺。坑在多轮工具调用里。

你想想这个场景:模型先「思考」一通,然后决定调一个工具(tool_use)。我们执行完工具,把结果喂回去,让它接着想下一步。问题是:第二轮调用时,第一轮那段 reasoning_content 如果不带上,会发生什么?

会胡说。DeepSeek 在多轮工具调用里,依赖上一轮自己的推理链来保持思路连贯。你把它前一轮的「草稿纸」擦掉了,它就接不上了------轻则重复劳动,重则给出和上文矛盾的结论。我那天的现象就是:单轮问答好好的,一旦涉及连续工具调用,模型答得驴唇不对马嘴。

解法是让 reasoning_content 跟着对话往返,分两个阶段:

  • SAVE 阶段(持久化) :模型这一轮返回的 reasoningContent,AgentRunner 必须把它存进这条 assistant 消息里(连同 tool_use 一起落到历史,也落到磁盘)。
  • BUILD 阶段(取出回填) :下一轮构建请求时,适配器把存着的 reasoning_content 重新挂回这条 assistant 消息上,再发出去。

BUILD 侧的代码就藏在 toOpenAIMessages 里,只有几行,但少了它就出 bug:

javascript 复制代码
// providers/openai-compat.ts · toOpenAIMessages()
if (m.role === 'assistant') {
  const hasTools = (m.toolCalls?.length ?? 0) > 0
  const reasoning = m.reasoningContent ?? ''
  if (hasTools || reasoning) {
    row.reasoning_content = reasoning   // ⭐ 把上一轮的推理链原样带回去
  }
}

画成图,这个「往返」一目了然:

记一句话就行:对 DeepSeek 这类模型,推理链是对话状态的一部分,丢了就胡说。这也是「一套接口兜三家」最隐蔽的代价------你以为只是格式转换,其实有些「方言」连状态都得帮它管。


2. 故障转移:主模型挂了,自动切,AgentRunner 无感

适配层解决了「调哪家、怎么调」。下一个问题:主模型整个挂了怎么办? DeepSeek 半夜限流、Anthropic 529 overloaded------总不能让用户干等。

catbuddy 的答案是 FallbackProviderproviders/fallback.ts)。用户配一个「主模型」+ 一个 fallbackModels 列表,工厂函数把它们串成一条链。这里的精髓是:FallbackProvider 本身也是一个 LLMProvider------它继承同一个基类、对外暴露同一个接口。这是装饰器模式 :外层包了一层「先试主、不行试备选」的逻辑,但 AgentRunner 接到手里的,还是那个熟悉的 LLMProvider,它压根不知道自己手里这个是单模型还是一条故障转移链。

2.1 工厂怎么把链串起来

javascript 复制代码
// providers/factory.ts
export function createProvider(config): LLMProvider {
  const primary = buildProvider(config, defaults.model, defaults.provider)

  const fallbackModels = config.agents.defaults.fallbackModels ?? []
  if (fallbackModels.length === 0) return primary   // 没配 fallback,直接返回裸 primary

  const fallbacks: LLMProvider[] = []
  for (const fb of fallbackModels) {
    try {
      fallbacks.push(buildProvider(config, /* 从预设名或内联配置解析 */))
    } catch (err) {
      console.warn(`[factory] fallback 初始化失败,跳过: ${err.message}`)  // 吞掉,不阻塞别的
    }
  }
  if (fallbacks.length === 0) return primary
  return new FallbackProvider({ primary, fallbacks })  // ⭐ 装饰一层,对外还是 LLMProvider
}

fallbackModels 支持两种写法:字符串(引用 modelPresets 里的预设名,如 "fast"),或内联对象({ model: 'gpt-4o', provider: 'openai' })。某个 fallback 初始化失败(比如没配 API Key),catch 直接吞掉跳过,不连累其他------能用几个算几个

2.2 FallbackProvider:按序试,永远给个有意义的兜底

javascript 复制代码
// providers/fallback.ts · _tryWithFallback()
const providers = [this.primary, ...this.fallbacks]
for (let i = 0; i < providers.length; i++) {
  const provider = providers[i]
  try {
    const response = await provider[method]({ ...opts, model: opts.model ?? provider.defaultModel })
    if (response.finishReason !== 'error') {
      if (i > 0) console.log(`[fallback] 已切到 fallback #${i}: ${provider.name}`)
      return response                      // ✅ 这家成了,立刻返回
    }
    continue                               // ❌ 这家报错 → 直接试下一家
  } catch (err) {
    console.warn(`[fallback] ${provider.name} 抛异常: ${err.message}`)
    continue
  }
}
// 所有 provider 全挂
return { content: 'All providers failed. Please check your API keys and network connection.',
         finishReason: 'error', /* ... */ }

整条链画出来就是一条「逐级降级」的瀑布:

这里有两个我特别想强调的设计判断:

一、不死磕「瞬态 vs 永久」,能试就试。 你可能觉得应该只在「瞬态错误」(限流、5xx)时才切下一家。但 catbuddy 的策略是:就算这家报的是配额耗尽这种看着「永久」的错,也继续试下一家 。原因很实在------isTransientError 的判断本来就是启发式的、不可靠;而且你在不同 provider 上的预算/配额情况可能完全不同。多试一家的成本,远低于错过一个本来能用的 provider。

二、FallbackProvider 自己覆盖了 chatStreamWithRetry ,避免重试爆炸。 注意:链式切换走的是各家的 chatStream不是 各家的 chatStreamWithRetry。为什么?否则就成了「主模型重试 3 次 × 备选重试 3 次 = 9 次调用」的指数膨胀。故障转移管「换人」,单机重试管「同一个人再试试」,两件事得分开,不能叠乘。

最坏情况下那句 All providers failed. Please check your API keys and network connection.------它不是随手写的兜底。它一句话说清了三件事:发生了什么 (调用失败)、可能为啥 (Key 或网络)、你能干啥 (去检查)。对比一片空白的白屏,这条消息至少让用户有下一步。这是贯穿整个韧性设计的铁律:永远不在用户面前崩溃。

2.3 热切换:/model 命令背后,Provider 怎么换而不重启

故障转移是「被动」换模型。还有「主动」换------用户在对话框里敲一句 /model gpt-4o

/model 命令本身的三层路由我们在第 03 篇讲过了,这里只看它触发的热切换效果。)

热切换的核心诉求很苛刻:Agent 不能重启、不能中断当前对话,但下一轮就得用新模型。 而且换一个模型,要同步通知一串子系统------AgentRunner、记忆合并器 Consolidator、后台学习 Dream、子 Agent,它们全在用同一套 Provider。

这里有个很关键的优化:model_presets.ts 给每个配置算一个 Signature ------本质是「模型名 + 关键配置」的哈希(实现上是 ['model_preset', 预设名, JSON.stringify(预设配置)] 这样一个元组)。切换时先比 Signature:

javascript 复制代码
// agent/loop.ts · _applyProviderSnapshot()
const sig = JSON.stringify(snapshot.signature)
if (sig === prevSig) return   // ⭐ 配置没变,啥都不干,绝不重复 new 一个 Provider

// 真变了 → 才广播给所有子系统
this.provider = snapshot.provider
this.runner.setProvider(snapshot.provider)               // Agent 执行器
this.consolidator.setProvider(snapshot.provider, /*...*/) // 记忆合并
this.dream?.setProvider(snapshot.provider, /*...*/)       // 后台学习
this.subagents?.setProvider(snapshot.provider, /*...*/)   // 子 Agent

Signature 一致就直接 return,避免「配置没变也重新初始化一遍 Provider」的浪费(重建一个 Provider 要重连 SDK、重新探测能力,不便宜)。整个切换全程同步、零重启、零中断 ------正在跑的那个 turn 用旧 Provider 跑完,下一个 turn 自动用新的AgentRunner.setProvider(newProvider),一行,搞定。


3. 三层重试:单次调用的瞬态错误,怎么各自自愈

故障转移解决的是「整个模型 不可用」。但更高频的,是单次调用里的小毛病:网络抖一下、模型回个空串、输出写到一半被截断。这些用不着换模型,就地自愈就行。

catbuddy 在这里铺了三层防线,关键设计原则是:每一层只吞自己能处理的错,处理不了的原样往上传

注意这两层的「位置」:① Provider 重试在最底层 ,紧贴 API;③ AgentRunner 自恢复在最外层,因为只有跑完一整次调用、拿到完整响应,才知道模型是「回了空串」还是「被截断」。中间其实还夹着第 2 节那条 Fallback 链(「答不上来且重试无效 → 换模型」)。三者层层嵌套,从内到外:单机重试 → 换模型 → 内容级补救。

3.1 第一层:Provider 重试------只重试「该重试的」,指数退避

最底层是 chatStreamWithRetry(写在基类,所有 Provider 共享)。它干两件事:指数退避 + 只重试瞬态错误

javascript 复制代码
// providers/base-provider.ts
async chatStreamWithRetry(opts): Promise<LLMResponse> {
  const maxAttempts = opts.retryMode === 'persistent' ? Infinity : 3
  const baseDelays = [1, 2, 4]  // 1s → 2s → 4s

  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    try {
      const response = await this.chatStream({ /* ... */ })
      if (response.finishReason !== 'error') return response   // 成了
      if (!this.isTransientError(response)) return response     // 永久错,重试也没用,直接返回

      const delay = response.retryAfter ?? baseDelays[Math.min(attempt, 2)]  // 429 可能自带 retryAfter
      await sleep(delay * 1000)
    } catch (err) {
      if (err.name === 'AbortError') throw err   // ⭐ 用户 /stop,立刻穿透,绝不等退避
      await sleep(baseDelays[Math.min(attempt, 2)] * 1000)
    }
  }
  return { content: 'Error: max retries exceeded', finishReason: 'error', /* ... */ }
}

三个要点:

①「该不该重试」由 isTransientError 拍板,克制得很:

javascript 复制代码
protected isTransientError(response: LLMResponse): boolean {
  if (response.errorShouldRetry !== undefined) return response.errorShouldRetry
  if (response.errorStatusCode === 429) return true                  // 限流 → 等等就过
  if (response.errorStatusCode && response.errorStatusCode >= 500) return true  // 服务端故障 → 换个实例可能就好
  if (response.errorKind === 'timeout' || response.errorKind === 'connection') return true // 网络抖动 → 重连
  return false  // 401 Key 错、400 格式错 → 重试 100 次也没用,别浪费 token
}

429(Rate Limit)是特殊照顾的 :除了判定为瞬态可重试,它还会优先读响应里的 retryAfter------服务端明说「X 秒后再来」,那就等 X 秒,而不是傻等固定的 1/2/4。对 4xx(除 429)这种客户端错误,一律不重试------你 Key 错了、格式错了,重试再多次结果都一样,还可能把你推向更严厉的限流。

② AbortError 直接穿透,绝不重试。 用户点了 /stop,AbortController 触发。如果这时候你还卡在退避里等 4 秒,用户会觉得「/stop 按了仨秒了它还在吐字」。AbortError 不是「错误」,是「用户想停下来」------优先级最高,立刻穿透所有重试层。

③ 两种重试模式standard(默认,最多 3 次)给用户的正常对话------三次还不行多半是真有问题,再等没意义;persistent(无上限)给后台任务,比如 Dream 在后台提炼记忆、Consolidator 压缩历史------没人在等,多试几次无所谓。但不管哪种模式,AbortError 都穿透

3.2 第二层:模型「答了,但答了个寂寞」------空回复重试

Provider 层管的是「模型能不能响应」。可有时候模型响应了,却给你一个空字符串------没 text,没 tool_calls,啥都没有。通常是它「卡住了」,或者上一个 tool result 把它搞懵了。

这种 Provider 层发现不了(在它看来这次调用「成功」了),得 AgentRunner 在循环里兜:

javascript 复制代码
// agent/runner.ts
if (!finalContent?.trim() && emptyRetries < MAX_EMPTY_RETRIES) {
  emptyRetries++
  messages.push({
    role: 'user',
    content: 'Please provide your response to the user based on the conversation above.',
  })
  continue   // 推一条引导消息,再转一轮
}

不报错,而是塞一条「请基于上文给出你的回复」进去,让它再试一次。最多 2 次。

3.3 第三层:输出被 max_tokens 拦腰截断------续写恢复

最后一种:模型正写一个长文件,写到一半撞上 max_tokens 上限,finishReason 变成 max_tokens,话说一半断了。

AgentRunner 检测到这个信号,发一条「接着写」让它续上:

javascript 复制代码
// agent/runner.ts
if (response.finishReason === 'max_tokens' && lengthRecoveries < MAX_LENGTH_RECOVERIES) {
  lengthRecoveries++
  messages.push({
    role: 'user',
    content: 'Output limit reached. Continue exactly where you left off --- no recap, no apology.',
  })
  continue   // 续写内容会接到之前的输出后面
}

最多 3 次------超过 3 次还没写完,多半是模型陷入了无限重复,再续也是浪费。

这条引导消息的措辞我反复磨过no recap, no apology(别复盘、别道歉)。不加这句,模型的典型反应是:「好的,让我继续。首先回顾一下我之前做了什么......」------然后半条命的 token 全花在复盘上,正事没干几个字。一句精准的提示词,省一半 token。这跟空回复那条「请基于上文给出回复」一样,都是用提示词把模型从歧路上拽回来

顺带说一句:工具执行抛异常时,AgentRunner 也不崩溃,而是把异常包成 Error: ${err.message} 当作 tool result 喂回给模型------让模型自己看到错误、自己换参数或换工具。代码只负责如实报告,决策权留给 LLM。 这条线属于工具系统,前面第 04 篇细讲过,这里不展开。


4. 三段连起来:一条消息怎么被层层兜住

把三段拼回去,一次有惊无险的调用是这样的(看日志最直观):

javascript 复制代码
[run] 调用 provider.chatStreamWithRetry()
[run] [fallback] primary (deepseek) 报 429 限流 ...        ← 故障转移层接住
[run] [fallback] 已切到 fallback #1: anthropic/claude-sonnet  ← 切到备选,成功
[respond] [DONE] turn completed {totalMs: 14000}            ← 用户全程无感

AgentRunner 从头到尾只调了一次 chatStreamWithRetry,至于中间是重试了、还是切了模型、还是续写了------它一概不知道,也不需要知道。这就是第 01 篇那条主线在「韧性」这一层的兑现:每个器官只认接口、不认实现,改动被锁在单个器官内部。 加一家新 provider,AgentRunner 一行不改;调一调重试策略,Provider 之外的代码毫无感知。

这篇讲了什么?

  1. 适配层LLMProvider 抽象基类把「重试、role 交替、错误分类」这些公共逻辑收在基类,两个子类 AnthropicProvider / OpenAICompatProvider 各自实现 chatStream() 消化三家差异(system 是参数还是消息、tool 结果 role、流式 tool_call 拼接)。AgentRunner 只调 chatStreamWithRetry(),永远不知道底层是谁。最隐蔽的坑:DeepSeek 的 reasoning_content 必须 SAVE 后在下一轮 BUILD 回填,否则多轮工具调用会胡说。
  2. 故障转移FallbackProvider 用装饰器模式把主模型 + fallbackModels 串成链,对外还是同一个接口;「能试就试」不死磕错误类型,全挂了给一条有意义的兜底消息。/model 热切换靠 Signature 比对避免重复初始化,当前 turn 跑完下个 turn 自动换,零重启。
  3. 三层重试 :① Provider 层指数退避(1s/2s/4s,最多 3 次,429 特殊处理、AbortError 穿透);② 空回复注入引导消息(最多 2 次);③ max_tokens 截断注入「接着写别复盘」续写(最多 3 次)。每层只吞自己能处理的,处理不了的往上传------核心铁律是「永远不在用户面前崩溃」。

下一篇预告 :这三层重试是硬编码 在循环里的。可如果我想在工具执行前后加监控、接个 Langfuse 追踪、塞点项目特定的逻辑------总不能每次都去改 AgentRunner 的源码吧?下一篇就讲 catbuddy 怎么用 Hook 系统在循环的关键节点开洞,让你不碰一行核心代码就能把扩展插进来。

相关推荐
chaors2 小时前
DeepResearchSystem 0x06:LLM as Judge
llm·agent·ai编程
浪遏3 小时前
02|50 行跑通一个 Agent Loop:harness 的最小内核
ai编程
小虎AI生活4 小时前
workbuddy 获客自动化,每天让浏览器 Agent 帮你巡场
ai编程
wechatbot8888 小时前
企业微信API开发:登录-联系人查询-消息发送完整开发流程分享
汇编·微信·自动化·企业微信·ai编程·rpa
AI大模型-小华8 小时前
Codex 反复重试仍完不成任务?判断 ChatGPT Plus 是否需要调整到 Pro
人工智能·chatgpt·ai编程·codex·开发效率·chatgpt plus·chatgpt pro
ajassi200011 小时前
AI语音智能体开发日记(三)解决小程序配网中的蓝牙命名与MAC地址获取问题
ai·apache·ai编程
vibecoding7715 小时前
GitHub 2026 年 7 月热榜:累计 Star 总榜与月度飙星榜
人工智能·github·ai编程