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(dest="web_action") fetch_p = sub.add_parser("fetch", help="抓取网页正文") fetch_p.add_argument("url", help="目标 URL") fetch_p.add_argument("--max-chars", type=int, default=3000) submit_p = sub.add_parser("submit", help="提交表单") submit_p.add_argument("url", help="表单地址") submit_p.add_argument("--data", nargs="*", default=[], help="k=v 键值对") def execute(self, args) -> int: if args.web_action == "fetch": text = FetchPageTool().run(url=args.url, max_chars=args.max_chars) print(text) elif args.web_action == "submit": data = dict(kv.split("=", 1) for kv in args.data) result = SubmitFormTool().run(url=args.url, data=data) 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_factory=lambda: {"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", encoding="utf-8") as f: data = yaml.safe_load(f) return SiteConfig(**data)顺便说下为什么用httpx而不是requests。有两个原因:一是httpx.Client原生支持follow_redirects,并且复用连接池,登录后的多次请求可以共享同一个 TCP 连接,效率明显更好;二是如果以后想把连接器异步化,httpx 直接支持 async,requests 做不到。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_redirects=True, timeout=15) 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(" ", strip=True).split()) return text[:max_chars]这里有几个细节值得展开说。
第一,resp.raise_for_status()必须保留。很多教程忽略状态码检查,结果把 404 页面当成正常结果喂给模型,模型会一本正经地分析"页面不存在",非常坑。第二,get_text(" ", strip=True)把 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_redirects=True, timeout=15) as client: if method.upper() == "POST": resp = client.post(url, data=data, headers={"User-Agent": "OpenCLI/1.0"}) else: resp = client.get(url, params=data, 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(headless=self.headless) page = browser.new_page(user_agent="OpenCLI/1.0") page.goto(url, timeout=self.timeout * 1000, wait_until="domcontentloaded") if wait_selector: page.wait_for_selector(wait_selector, timeout=self.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( model=model, messages=messages, 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 响应头里没有 charset,httpx默认按 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排查时先看状态码,再看耗时,最后看返回长度,基本能定位。特别是返回长度异常短的时候,十有八九是会话失效或页面改版,不用急着怀疑代码逻辑。我把这些高频问题整理成了一张速查表,贴在项目文档开头,团队里遇到类似问题能照着排查。
| 症状 | 可能原因 | 快速定位方法 |
|---|---|---|
| 返回 403 | UA 被识别或请求频率过高 | 检查响应头、切换 UA、加限速与重试 |
| 中文乱码 | 编码推断错误 | 设置 force_encoding 或使用 apparent_encoding |
| 返回登录页 | 会话失效或 Cookie 过期 | 看最终 URL、Set-Cookie、Cookie 文件时间戳 |
| 空内容但状态码 200 | 页面改版或选择器失效 | 看返回长度、关注 empty_content_warning |
5. 测试与落地:让二次开发成果不只在本地能用
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_value=httpx.Response(200, text="<html><body><article>Hello OpenCLI</article></body></html>") ) result = FetchPageTool().run(url=url, max_chars=500) assert "Hello OpenCLI" in result这个测试不需要真实网络,跑得飞快,CI 里也能稳定通过。注意一点:如果代码里用的是httpx.Client(),respx默认拦截真实发送的请求,但前提是你的工具确实走了 httpx。我之前吃过亏,工具里用了urllib,mock 完全不生效,只能重构改用 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 的自主调用能力再强,如果工具本身的边界不清楚、失败不明确,模型就会在同一个坑里反复打转。把每个连接器的输入输出定义清楚,把失败情况显式反馈给模型,整个系统的可用性会立刻上一个台阶。如果让我重做一次,我会先花半天把工具描述和日志规范写好,再动业务逻辑。