ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

OpenCLI二次开发实战:给AI命令行工具装上网站连接器

OpenCLI二次开发实战:给AI命令行工具装上网站连接器 1. 为什么做 OpenCLI 二次开发从会聊到会连1.1 痛点AI 再聪明也看不到网页上个月我把 OpenCLI 的二次开发需求排进了迭代计划。原因很简单团队里几个同事已经把 OpenCLI 当成日常 AI 入口来用了。这个开源命令行工具能接各家大模型 API支持多轮对话、会话存档、角色设定确实比在网页里来回切换舒服。可一旦遇到需要查实时网页内容的场景就卡住了——AI 只能基于我贴给它的文本回答没法自己去访问站点。于是就有了这个项目对 OpenCLI 做二次开发让它具备连接任意网站的能力。先说 OpenCLI 本身。它是一个基于 Python 的命令行 AI 工具核心能力是把 OpenAI 兼容的 chat/completions 接口封装成顺手好用的 CLI。你可以把它理解成一个交互壳模型换谁无所谓反正走统一接口。默认安装后opencli chat进入普通对话opencli agent进入半自动 Agent 模式插件通过tools/目录自动发现。但这些能力都是只读输入——你喂什么它聊什么模型自身没有与外部世界交互的通道。这个问题在真实工作里非常明显。举个例子产品同学想快速看某电商页面现在挂出的促销文案常规操作是复制 URL、打开浏览器、复制正文、粘贴给 AI。一次两次还行每天都来十几条就开始崩溃。再比如运营想让 AI 根据某站点搜索页的结果整理竞品清单人工复制粘贴根本做不过来。OpenCLI 的问题不在于模型不够强而在于缺少传感器——它没有抓取网页、提交表单、保持登录态的手段。换句话说AI 的眼睛和手都是断开的。1.2 二次开发的核心思路连接器加工具注册我们的目标很明确给 OpenCLI 加一个网站连接器。实现路径分成三层。底层是连接器本身负责 HTTP 请求、HTML 解析、表单提交、Cookie 管理中间是工具注册层把连接器能力封装成 AI 可以调用的 function上层是 CLI 命令层让使用者可以直接敲opencli web fetch url手动触发。这样既保留了人工操作路径又给了模型自主调用的入口。没有把逻辑直接写死在 chat 循环里而是拆成可独立测试的工具这个取舍是有教训的。之前做个内部小项目我把抓取逻辑和对话逻辑写在一起结果改一处要重启整个服务状态一乱很难查。现在每个连接器都是一个类有名字、描述、参数 schema、run 方法注册进 registry 之后AI 和 CLI 两边都能复用。这套思路本质上就是命令模式加策略模式的组合很多二次开发场景都适用。这次二次开发完成后适合谁参考我认为有三类人一类是在用 OpenCLI 或类似 CLI 工具、希望让 AI 拥有联网能力的人一类是在做企业内部系统接口封装、想找到一个通用对接范式的开发还有一类是纯粹对给开源工具做扩展感兴趣想知道插件机制怎么设计的同学。下面内容会从架构、代码、踩坑和测试逐一展开。2. 读懂 OpenCLI 的插件机制二次开发的地基2.1 源码结构与插件加载方式要二次开发先得搞清楚插进去的位置。OpenCLI 的源码结构不复杂核心模块就几个入口opencli.py负责解析全局参数、分发子命令core/下面放 AI 客户端、配置管理和注册表commands/下面每个子命令一个文件tools/下面放所有可被模型调用的工具。插件机制走的是目录扫描加注册表登记方式启动时遍历tools/下所有继承自Tool基类的类实例化后塞进全局 registry。这个设计对二次开发非常友好。你不需要改核心代码只要在tools/下新增一个文件实现好name、description、parameters_schema、run四个东西重启后工具就自动出现在可用列表里。有一点要注意注册顺序是按模块导入顺序来的如果新增工具和已有工具同名后者会覆盖前者。我们第一次提交时就因为fetch_page和已有工具重名导致 AI 连续调用错误工具排查半天才发现是命名冲突。Tool 基类的代码是所有工具的公共契约它长这样# opencli/tools/base.py from abc import ABC, abstractmethod class Tool(ABC): name: str description: str abstractmethod def parameters_schema(self) - dict: OpenAI tool schema 风格的参数定义 abstractmethod def run(self, **kwargs) - str: 执行工具逻辑返回字符串给模型或用户 def to_openai_tool(self) - dict: return { type: function, function: { name: self.name, description: self.description, parameters: self.parameters_schema(), }, }这里的to_openai_tool是为了对接 chat/completions 的 tools 参数。OpenCLI 在请求模型时会把 registry 里的所有工具统一转成这种格式模型判断需要联网时会在返回的tool_calls里指明工具名和参数OpenCLI 调用对应run方法把结果追加进消息历史继续下一轮。整个回环不难难的是让回环里的工具真正稳定可用这也是后面章节反复强调的重点。2.2 命令扩展与 AI 工具注册两条路线如果你只是想让 AI 能联网做工具注册就够了。但如果你和我一样团队里已经有同事习惯用终端敲命令我建议两条路线一起做一条是 CLI 子命令供人直接操作另一条是工具注册供模型自主调用。两条路线共享底层连接器实现只是入口不同。CLI 子命令的扩展方式是在commands/web.py里新增一个WebCommand类实现add_parser和execute两个方法然后在入口文件里注册。工具注册则是把同一个FetchPageTool实例加进 registry。注意命名空间要区分开CLI 命令叫web fetch工具名就叫fetch_page避免用户手敲命令时和工具名混淆。# opencli/commands/web.py import argparse from opencli.tools.web_connector import FetchPageTool, SubmitFormTool class WebCommand: name web def add_parser(self, subparsers) - None: p subparsers.add_parser(self.name, help连接任意网站) sub p.add_subparsers(destweb_action) fetch_p sub.add_parser(fetch, help抓取网页正文) fetch_p.add_argument(url, help目标 URL) fetch_p.add_argument(--max-chars, typeint, default3000) submit_p sub.add_parser(submit, help提交表单) submit_p.add_argument(url, help表单地址) submit_p.add_argument(--data, nargs*, default[], helpkv 键值对) def execute(self, args) - int: if args.web_action fetch: text FetchPageTool().run(urlargs.url, max_charsargs.max_chars) print(text) elif args.web_action submit: data dict(kv.split(, 1) for kv in args.data) result SubmitFormTool().run(urlargs.url, datadata) print(result) return 0这段代码里我故意在execute中直接实例化工具而不是用全局单例。原因在于哪怕某次只注册了命令、没注册工具手动抓取功能依然可用。这个细节在团队协作时很管用因为不是所有人都需要 AI 自主调用能力但每个人都可能临时想在终端里 fetch 一个页面看看。3. 实战给 OpenCLI 装上网站连接器3.1 定义连接器工具基类与站点配置任何网站连接需求最终都会落到几个原子操作抓取页面、搜索、提交表单、读取接口。我先定义了一个站点连接器的数据结构把每个站点的差异收敛到配置里。基类里放了三样东西一个httpx.Client负责请求一个SiteConfig负责站点配置一个响应解析方法负责把 HTML 转成结构化文本。# opencli/tools/web_connector.py from dataclasses import dataclass, field from typing import Optional import httpx from bs4 import BeautifulSoup dataclass class SiteConfig: name: str base_url: str headers: dict field(default_factorylambda: {User-Agent: OpenCLI/1.0}) login_url: Optional[str] None username_field: Optional[str] None password_field: Optional[str] None cookie_name: Optional[str] None content_selector: str article配置文件用 YAML而不是把站点结构写死在代码里。原因很直接站点会经常变页面改版、登录字段改名如果每次都要改代码再发布维护成本太高。配置文件把站点结构和抓取逻辑分离站点改版时只需要更新配置连接器代码可以保持稳定。import yaml def load_site_config(path: str) - SiteConfig: with open(path, r, encodingutf-8) as f: data yaml.safe_load(f) return SiteConfig(**data)顺便说下为什么用httpx而不是requests。有两个原因一是httpx.Client原生支持follow_redirects并且复用连接池登录后的多次请求可以共享同一个 TCP 连接效率明显更好二是如果以后想把连接器异步化httpx 直接支持 asyncrequests 做不到。OpenCLI 本身是同步 CLI 工具我用的是同步模式但接口设计上已经为异步化留了余地。3.2 静态网页抓取与正文提取第一步实现的是静态网页抓取。所谓静态就是服务器直接返回 HTML不需要前端执行 JavaScript。对这类页面httpx.get加BeautifulSoup足够。class FetchPageTool(Tool): name fetch_page description 抓取指定网页的正文并返回纯文本适用于公开静态页面 def parameters_schema(self) - dict: return { type: object, properties: { url: {type: string, description: 完整 URL包含协议前缀}, max_chars: {type: integer, description: 最大返回字符数默认 3000}, }, required: [url], } def run(self, url: str, max_chars: int 3000) - str: with httpx.Client(follow_redirectsTrue, timeout15) as client: resp client.get(url, headers{User-Agent: OpenCLI/1.0}) resp.raise_for_status() soup BeautifulSoup(resp.text, html.parser) for tag in soup([script, style, nav, footer, aside]): tag.decompose() text .join(soup.get_text( , stripTrue).split()) return text[:max_chars]这里有几个细节值得展开说。第一resp.raise_for_status()必须保留。很多教程忽略状态码检查结果把 404 页面当成正常结果喂给模型模型会一本正经地分析页面不存在非常坑。第二get_text( , stripTrue)把 HTML 转成单行文本再用split()合并多空格是为了避免表格、列表给模型造成杂音。第三max_chars参数是截断而不是丢弃因为大模型上下文窗口有限一次塞几十万字符既浪费 token又可能让模型忽略真正的重点。至于正文提取一个简单的选择器能覆盖大部分公开文章页。复杂页面比如信息流、评论区我会在SiteConfig.content_selector里配置连接器读取配置后先按选择器抽取目标区域再做清洗。用选择器而不是全文抓取还有个附带好处站点改版通常是改样式层HTML 结构变化不大选择器配置比全文抓取更抗变化。3.3 表单提交与登录态保持很多网站不是公开页面要查数据得先登录。表单提交工具就是为这个准备的。它不只处理登录表单还能处理搜索、筛选这类常见表单。class SubmitFormTool(Tool): name submit_form description 向指定的 URL 提交表单数据支持 POST 和 GET def parameters_schema(self) - dict: return { type: object, properties: { url: {type: string, description: 表单提交地址}, data: {type: object, description: 表单字段如 {\q\: \OpenCLI\}}, method: {type: string, enum: [POST, GET], default: POST}, }, required: [url, data], } def run(self, url: str, data: dict, method: str POST) - str: with httpx.Client(follow_redirectsTrue, timeout15) as client: if method.upper() POST: resp client.post(url, datadata, headers{User-Agent: OpenCLI/1.0}) else: resp client.get(url, paramsdata, headers{User-Agent: OpenCLI/1.0}) resp.raise_for_status() return extract_main_text(resp.text)登录态保持需要单独处理。最朴素的做法是在配置里写明登录 URL 和账号字段名连接器启动时先跑一次登录把返回的 Cookie 保存到本地CookieJar文件里之后所有请求都带着这份 Cookie直到过期。这里容易踩一个坑很多站点的登录是两步的先 POST 用户名密码返回一个跳转页再带 token 跳到真正的登录接口。如果只 POST 一次就认为成功后续请求就会在 302 循环里打转。我的建议是登录逻辑写成可验证的POST 之后检查最终 URL 和页面特征用配置里的login_success_selector判断是否真的登录成功不成功就抛异常并保留现场日志。这样至少能让你知道是登录失败了而不是稀里糊涂拿着错误会话发请求。另外Cookie 文件要定期清理。过期 Cookie 不会带来新会话还可能让请求带上两个冲突的 session id服务端随机选一个结果时好时坏。我们后来增加了--refresh-login参数需要时手动强制重新登录比每次请求都自动尝试登录更可控。3.4 用 Playwright 补上动态渲染的坑静态抓取和表单提交能覆盖多数场景但总有漏网之鱼页面数据是前端 JS 动态渲染的直接 fetch 拿不到或者站点加了 JS 挑战必须浏览器环境才能过。对这类页面我在连接器里补了BrowserFetchTool用 Playwright 驱动无头浏览器。# 需要单独安装pip install opencli[browser] from playwright.sync_api import sync_playwright class BrowserFetchTool(Tool): name browser_fetch description 用无头浏览器抓取动态渲染的网页适用于普通 HTTP 拿不到内容的场景 def __init__(self, headless: bool True, timeout: int 30): self.headless headless self.timeout timeout def parameters_schema(self) - dict: return { type: object, properties: { url: {type: string}, wait_selector: {type: string, description: 等待某个选择器出现后再抓取如 .content}, }, required: [url], } def run(self, url: str, wait_selector: str | None None) - str: with sync_playwright() as pw: browser pw.chromium.launch(headlessself.headless) page browser.new_page(user_agentOpenCLI/1.0) page.goto(url, timeoutself.timeout * 1000, wait_untildomcontentloaded) if wait_selector: page.wait_for_selector(wait_selector, timeoutself.timeout * 1000) html page.content() browser.close() return extract_main_text(html)这里最值得强调的就是wait_selector参数。动态页面不是一次渲染完的直接抓 HTML 很可能只拿到骨架。给工具一个等待条件让调用者告诉它等什么出现再抓比固定 sleep 三秒可靠得多。固定 sleep 是典型的不稳定定时炸弹网络慢时三秒不够网络快时白等三秒。性能方面也说一下。无头浏览器每次启动约一到两秒如果 AI 在一次对话里连续调用多次browser_fetch体验会明显下降。我在实现里加了浏览器实例池60 秒内复用同一个浏览器实例实测能省一半以上时间。这对 token 消耗也有帮助因为页面更快拿到模型等待时延更短整体交互更顺。提示无头浏览器不是万能钥匙。遇到验证码、强风控的站点靠 Playwright 只能撑过第一层。真实的对抗要么人工介入要么走正规授权接口别把精力耗在绕过风控上。不过要冷静看待无头浏览器。它依然会被强反爬站点识别有些站点还会弹验证码。这种场景已经不是二次开发能解决的问题需要专门的验证码处理或人工介入。不要指望一个工具解决所有问题要在工具描述里明确限制和适用场景。3.5 接入 AI 对话循环让模型自己决定访问哪个网站工具本身做好后最后一步是接进 AI 对话循环。OpenCLI 的agent模式已有 function calling 回环请求模型时带上 tools解析返回的tool_calls执行再把结果追加回消息。我们要做的就是把新工具塞进 tools 列表。# opencli/agent.py 的改动示意 from opencli.tools.web_connector import FetchPageTool, SubmitFormTool, BrowserFetchTool WEB_TOOLS [ FetchPageTool(), SubmitFormTool(), BrowserFetchTool(), ]注册之后你可以在对话里说帮我看看 example.com 上最新的公告然后总结成三点。 模型会自己选择合适的位置调用fetch_page拿到页面文本后继续回答。这一步之所以关键是因为它把网站连接器从手动命令变成了 AI 的自主能力等于给 AI 配上了一双能实时看网页的眼睛。但我也要提醒一句让模型自主访问网站必须给它足够好的工具描述。描述太抽象模型就会乱用。比如fetch_page只说抓取网页模型可能拿它去反复抓同一个页面或者去抓一个需要登录的 URL 然后回来告诉你没权限。我的经验是在描述里写清楚适用场景、限制、典型用法在参数说明里写清格式要求模型的行为会稳定很多。很多时候不是模型不够聪明而是工具描述写得不够清楚。还需要控制工具调用次数。OpenCLI 默认允许模型连续调用工具直到任务完成这在访问网站时有点危险某个页面抓取超时模型可能重试十几次。我在 agent 循环里加了max_tool_calls限制默认 8 次达到上限就停止让用户手动判断下一步。# agent.py 中简化后的调用循环 messages [{role: user, content: prompt}] for _ in range(max_tool_calls): resp client.chat.completions.create( modelmodel, messagesmessages, tools[t.to_openai_tool() for t in tools], ) msg resp.choices[0].message if not msg.tool_calls: break messages.append(msg) for call in msg.tool_calls: tool lookup_tool(call.function.name) result tool.run(**json.loads(call.function.arguments)) messages.append({role: tool, tool_call_id: call.id, content: result})这个数字可以按实际场景调整。抓取类任务 8 次通常够用涉及多页面对比分析时建议放宽到 15 次。要记住限制次数不是为了省 token而是防止模型在网站异常时进入无效的自我循环。4. 踩坑记录网站连接过程中的真实问题4.1 反爬、限流与请求头伪装第一次上线后马上遇到 403。有些站点检查 User-Agent有些检查 Referer有些检查请求频率。我们当时从三层逐步解决。第一层是基础请求头httpx.Client里统一设置常见浏览器的 UA 和 Accept 头这一层能过掉一半初级防护。第二层是限速与重试给连接器加min_interval配置同一个客户端两次请求至少间隔一到两秒同时用tenacity做指数退避重试遇到 429 或 5xx 最多重试三次。第三层是浏览器指纹绕过这一层我们不建议普通项目碰因为涉及站点风控对抗合规和维护成本都太高。还有一个容易忽略的细节不同 UA 可能拿到完全不同的页面。你用 Python 默认 UA 抓拿到的是简化版切到真实浏览器 UA 后页面结构直接变样之前配的解析选择器全部失效。所以选好一个 UA 就不要频繁换解析规则要跟着 UA 走。这个坑我们踩过最后在配置文件里固定了 UA并加了注释说明不要随意改动。4.2 编码乱码与解析失效抓取中文站点时最高频的问题是乱码。原因很简单HTTP 响应头里没有 charsethttpx默认按 UTF-8 解码遇到 GBK 或 GB2312 编码的页面就全乱了。解决办法是先看resp.encoding为空时用resp.apparent_encoding按字节内容推断编码。if not resp.encoding or resp.encoding.lower() iso-8859-1: resp.encoding resp.apparent_encoding注意apparent_encoding不是绝对可靠。遇到依然乱码的页面最快的办法是查看页面源码头部的 charset 声明然后在配置里手动指定编码。注意apparent_encoding不是万能的。有些页面在 meta 标签里声明了 charset有些页面声明是错误的推断结果依然不对。我的做法是在SiteConfig里加一个force_encoding字段站点维护者知道自己站点是什么编码直接指定省掉推断成本。解析失效是另一个高频问题。有人配置了content_selector: div.main-content用了半年没问题某天突然返回空文本。一查是前端开发把div换成了section。这种问题没有一劳永逸的方案只能靠监控。我在连接器里加了empty_content_warning逻辑提取结果少于 50 个字时在返回文本里附带页面可能改版当前选择器未匹配到足够内容。模型看到这句提示至少不会一本正经地分析一个空页面。4.3 登录态失效与 Cookie 持久化登录相关的问题最难排查因为报错不直接。最典型症状是用连接器抓某个后台页面返回的不是数据页而是登录页的 HTML。解析逻辑一切正常提取结果是用户名、密码、登录按钮但你就是不知道问题出在哪。我总结了一套排查顺序。先看最终 URL 是否变成了 login 地址是则说明会话失效然后看响应里的 Set-Cookie 是否出现新的 session id最后看本地 Cookie 文件的修改时间判断是否已经过期。这三个检查点按顺序走五分钟内能定位大部分登录问题。Cookie 持久化我用的是http.cookiejar.MozillaCookieJar登录成功后把client.cookies保存到本地文件下次启动时加载。这里有个教训MozillaCookieJar保存时需要指定文件路径加载时要先判断文件是否存在否则首次运行直接抛异常。另外Cookie 是有域名和生命周期概念的。保存时要确认 cookie 的 domain 覆盖站点所有子域否则登录了www.example.com再请求api.example.com依然是未登录状态。4.4 超时控制与日志排查连接器跑多之后我最大的感受是超时控制比功能本身还重要。没有超时的抓取请求可能让 AI 挂在那里等两分钟然后回你一句我还在努力。这个体验太差了。我分两级超时。第一级是 HTTP 连接超时httpx的timeout参数设成 15 秒第二级是整体任务超时比如表单提交包含登录加请求加解析整个链路超过 30 秒直接放弃返回一个明确错误。注意超时值不要设太小有些站点响应确实慢15 秒对正常请求够用但对慢站点就是煎熬需要按实际情况平衡。日志方面我给每个连接器都加了结构化日志统一输出请求方法、URL、状态码、耗时、返回文本长度。这样 AI 调用失败时你能从日志里看出是哪一步出错。日志格式长这样[web][fetch_page] GET https://example.com - 200, 0.86s, 10240 chars [web][fetch_page] GET https://example.com/nonexist - 404, 0.12s, 0 chars [web][submit_form] POST https://example.com/search - 302, 1.20s, 0 chars排查时先看状态码再看耗时最后看返回长度基本能定位。特别是返回长度异常短的时候十有八九是会话失效或页面改版不用急着怀疑代码逻辑。我把这些高频问题整理成了一张速查表贴在项目文档开头团队里遇到类似问题能照着排查。症状可能原因快速定位方法返回 403UA 被识别或请求频率过高检查响应头、切换 UA、加限速与重试中文乱码编码推断错误设置 force_encoding 或使用 apparent_encoding返回登录页会话失效或 Cookie 过期看最终 URL、Set-Cookie、Cookie 文件时间戳空内容但状态码 200页面改版或选择器失效看返回长度、关注 empty_content_warning5. 测试与落地让二次开发成果不只在本地能用5.1 用 mock 响应做单元测试连接器代码里全是外部依赖直接写发真实请求的测试又慢又不稳定还有被封 IP 的风险。所以单元测试必须用 mock 响应。我这边用respx它可以直接拦截httpx的请求。# tests/test_web_connector.py import respx import httpx import pytest from opencli.tools.web_connector import FetchPageTool respx.mock def test_fetch_page_returns_text(): url https://example.com/article respx.get(url).mock( return_valuehttpx.Response(200, texthtmlbodyarticleHello OpenCLI/article/body/html) ) result FetchPageTool().run(urlurl, max_chars500) assert Hello OpenCLI in result这个测试不需要真实网络跑得飞快CI 里也能稳定通过。注意一点如果代码里用的是httpx.Client()respx默认拦截真实发送的请求但前提是你的工具确实走了 httpx。我之前吃过亏工具里用了urllibmock 完全不生效只能重构改用 httpx 才能测。除了 respx我还用本地起一个简单的http.server做端到端测试重点验证登录流程和重定向逻辑。端到端测试数量不要多两三个关键场景就够了跑多了维护成本很高。单元测试应该覆盖绝大多数解析、参数和异常分支端到端只验证集成点。5.2 打包发布与配置管理代码写完之后要能让团队其他人直接用就需要打包。OpenCLI 本身用pyproject.toml管理依赖和入口二次开发加的内容也要在项目配置里声明。我的做法是在[project.optional-dependencies]里加一个web分组把 httpx、beautifulsoup4、playwright 这些依赖列进去。安装时用pip install -e .[web]不需要的人可以不装浏览器相关依赖保持基础安装轻量。配置文件建议统一放~/.opencli/sites/每个站点一个 YAML。第一次运行时自动创建目录和默认配置。这里有个底线要求配置文件里不要写明文密码用环境变量或系统 keyring 引用。否则项目一公开凭据就全泄露了。发布时我习惯打三个版本 tag本地开发版、内部测试版、稳定版。OpenCLI 的二次开发改动往往跟着站点结构走站点每次改版都可能要发小版本。版本号用语义化版本别乱跳至少让使用者知道这个版本有没有破坏性变化。我把 changelog 写在项目的docs/CHANGELOG.md里每次发布前过一遍。5.3 从连接网站到连接业务系统的扩展思路写到这儿网站连接器的基本能力已经完整了。但我还想多说一句这套连接器思路不只能连公开网站同样能连企业内部业务系统。我们第二个迭代里就把同样的 Tool 架构接进了公司内部的 ERP 查询接口和 GIS 数据服务。原理完全一样只是把SiteConfig换成了内部服务配置把 HTML 解析换成了 JSON 字段映射。其实很多专业软件的二次开发比如 U9、金蝶、NX、CATIA、Revit 这些本质上也是在给既有系统扩展接口能力。如果你能在 OpenCLI 的插件机制里做好适配层把内部系统的查询、提交、审批包装成标准 Tool那 AI 就能像访问网站一样访问这些业务系统。这带来的架构变化是AI 不再停留在对话层而是真正变成一个能读写外部世界的工作台。你可以在一个会话里既查公开页面又查内部系统还能顺手生成报表只要每个环节都有对应的工具。到了这一步OpenCLI 就不再是聊天工具而是团队的统一自动化入口。这次的二次开发让我最深的体会是工具描述和超时控制远比想象的重要。AI 的自主调用能力再强如果工具本身的边界不清楚、失败不明确模型就会在同一个坑里反复打转。把每个连接器的输入输出定义清楚把失败情况显式反馈给模型整个系统的可用性会立刻上一个台阶。如果让我重做一次我会先花半天把工具描述和日志规范写好再动业务逻辑。
返回列表