如何用 CapSkip 给智能体添加 LangChain 验证码工具

要识别 LangChain 验证码,就给智能体一个调用 CapSkip 的工具,并让 token 不进入对话。最直观的工具会把 token 返回给模型,这会让下一次模型调用多出大约 1,700 个字符,之后的每一次调用里还要再多一份副本。更好的工具返回一个简短的句柄,把 token 留在你这一侧,并在第二个工具里使用它。识别工具应该捕获 CapSkip 的异常,因为从工具里逃出去的异常会结束整个运行。CapSkip 运行在你自己的 Windows PC 上,所以一个会重试的智能体没有按次识别的费用。本指南用 LangChain 1.4 构建这一对工具,并且不需要任何 API 密钥就能运行它。
你需要什么
- 在 Windows PC 上运行的 CapSkip,并已开启 reCAPTCHA 识别功能。 设置指南 会带你完成首次启动的每一步。
- Python 3.10 或更高版本。LangChain 1.4 和 capskip 1.3.0 包都要求它。下面的所有内容都在 Windows 11 上用 Python 3.14.0、langchain 1.4.6、langchain-core 1.6.12、langgraph 1.2.16、pydantic 2.14.1 和 requests 2.34.2 运行过。这些代码块也在 Python 3.11.7 上通过了。Python 3.10、3.12 和 3.13 没有运行过。
- 一个 PowerShell 提示符。这些命令使用 PowerShell 语法。
- 一个你自己拥有的注册页面,带有 reCAPTCHA v2 复选框。示例表单有一个名为 _csrf 的隐藏字段和一个名为 email 的字段。提交工具会提交这两个字段加上 token,所以请把名称改成与你的表单一致。
- 识别工具的地址。在 Local 模式下,CapSkip 只在 127.0.0.1 上响应,仅供本机使用。在 Server 模式下,它监听你的网络地址或公网 IP,这样另一台 PC、VPS 或托管平台上的智能体就能通过 API 调用它。两种模式的设置位置都是 连接设置.
写这份指南时没有调用任何语言模型。智能体的运行使用的是一个脚本化的模型,它按固定顺序发出工具调用,所以接线是真实运行的,但这并不能说明真实模型的行为。测量数据来自同一台 PC 上的一个模拟 CapSkip 和一个模拟注册页面,配合真实的 LangChain 和真实的 SDK 使用。本指南把它们称为模拟运行和模拟页面。模拟页面的接受和拒绝规则、模拟程序排队处理识别的方式,以及所有耗时,都只属于这台 PC 上的模拟程序本身。对一个运行中的 CapSkip 做的一次识别,使用 Google 的公开测试 sitekey 和默认设置,用了 12.9 秒,返回了一个 1,700 个字符的 token。识别耗时每次运行都不一样。
为什么工具比脚本更需要小心
在脚本里,参数由你选,结果由你读。在智能体里,参数由模型选,结果由模型读,而且智能体读过的页面可以试图左右模型。这会带来四个变化。结果会进入对话,并在之后的每一次调用中再次发送。工具内部的异常不会替你处理。模型想让你再识别多少次都可以。而且,来自模型的 sitekey 和地址,在到达 CapSkip 之前需要检查。让智能体自己驱动浏览器也有同样的问题,而 Browser Use 指南 讲的就是这条路线。本指南这里讲的是一个模型可以调用的普通函数。
第 1 步:创建项目
在一个新文件夹里使用虚拟环境。这些命令通过路径直接运行该环境自己的 Python,所以你不需要激活它。langchain 包会带进 langgraph、langchain-core 和 pydantic,总共大约 40 个包。模型提供方是单独的包,只有在使用真实模型时才需要添加。
# pip install langchain capskip mkdir langchain-captcha cd langchain-captcha python -m venv .venv .\.venv\Scripts\python -m pip install langchain capskip .\.venv\Scripts\python -c "import capskip, langchain; print(langchain.__version__)"
智能体请用 langchain.agents 里的 create_agent。旧的辅助函数已经没有了。从 langchain.agents 导入 AgentExecutor、initialize_agent 和 create_react_agent 会抛出 ImportError。langgraph.prebuilt 里的 create_react_agent 仍然能运行,并会打印一条 LangGraphDeprecatedSinceV10 警告,告诉你从 langchain.agents 导入 create_agent。用到其中任何一个的教程都已经过时了。
第 2 步:第一个工具,以及它为什么形态不对
把它保存为 naive_tool.py。这是最短的可用工具:识别验证码,然后把 token 交回去。SDK 不读取任何环境变量,所以文件自己读取主机、端口和密钥。只有在你于 CapSkip 里开启了 API key validation 时,密钥才有意义。每个文件都从 PAGE_URL 读取页面地址,从 SITEKEY 读取 sitekey,所以请在你的 PowerShell 窗口里,用你自己的注册页面把这两个都设置一次。对于没有 reCAPTCHA v2 小组件的页面,CapSkip 会返回错误。
# PowerShell: set these once per window, before you run any file $env:PAGE_URL = "https://your-site.example/signup" # your own page $env:SITEKEY = "YOUR_SITEKEY" # its data-sitekey
然后用 .\.venv\Scripts\python naive_tool.py 运行 naive_tool.py。
# naive_tool.py: the first version, token straight back to the model
import os
from capskip import CapSkip
from langchain.tools import tool
solver = CapSkip(
apiKey=os.getenv("CAPSKIP_API_KEY", "capskip"),
host=os.getenv("CAPSKIP_HOST", "127.0.0.1"),
port=int(os.getenv("CAPSKIP_PORT", "8080")),
)
@tool
def solve_recaptcha(sitekey: str, page_url: str) -> str:
"""Solve a reCAPTCHA v2 checkbox and return the token."""
return solver.recaptcha(sitekey, page_url)["code"]
if __name__ == "__main__":
args = {
"sitekey": os.getenv("SITEKEY", "YOUR_SITEKEY"),
"page_url": os.getenv(
"PAGE_URL", "https://example.com/page-with-recaptcha"),
}
token = solve_recaptcha.invoke(args)
print(len(token), "characters")它能用,并且会打印 token 的长度。问题恰恰就在这个长度上。针对 Google 公开测试 sitekey 识别出的 reCAPTCHA token 大约有 1,700 个字符。工具把它返回时,token 在工具结果里存了一份。模型接着必须把它作为参数传给下一个工具,于是它在模型自己的工具调用里又存了第二份。之后的每次调用都带着这两份副本。一次模拟运行统计了三次调用中,每次发送给模型的字符数,按消息文本加工具调用参数来算。它使用了脚本化模型、一个字符的系统提示、一个 2,000 个字符的模拟 token,并且在第一行里使用了第二个以 token 为参数的工具。真实的系统提示和工具 schema 会给两行加上相同的量:
| 设计 | 第 1 次调用 | 第 2 次调用 | 第 3 次调用 | 总计 |
|---|---|---|---|---|
| 返回 token 并传回去 | 11 | 2,096 | 4,146 | 6,253 |
| 返回简短句柄 | 11 | 152 | 253 | 416 |
作为参照,一个随机的 2,000 个字符的字符串,用 OpenAI 的分词器大约数出 1,400 个 token。其他模型的计数方式不同。这是关于成本和体积的论据,不是在说模型复制长字符串时多久会抄错一次。它也是又一个可能把只能用一次的有效 token 抄坏的地方。解决办法是把 token 留在你这一侧,给模型一个句柄。
第 3 步:真正工具的设置
真正的工具都放在一个文件 captcha_tools.py 里,你要用四个粘贴的部分把它构建出来。先保存这第一部分。它读取设置、构建客户端,并声明工具共用的少量状态。ALLOWED_HOSTS 来自你自己的 PAGE_URL,绝不来自模型。
# captcha_tools.py, part 1: settings
import logging
import os
import re
import secrets
import threading
import time
from typing import Literal
from urllib.parse import urljoin, urlparse
import requests
from capskip import ApiException, CapSkip, CapSkipError
from capskip import NetworkException, TimeoutException
from langchain.tools import tool
log = logging.getLogger("captcha_tools")
solver = CapSkip(
apiKey=os.getenv("CAPSKIP_API_KEY", "capskip"),
host=os.getenv("CAPSKIP_HOST", "127.0.0.1"),
port=int(os.getenv("CAPSKIP_PORT", "8080")),
)
PAGE_URL = os.getenv("PAGE_URL", "https://example.com/page-with-recaptcha")
SITEKEY = os.getenv("SITEKEY", "YOUR_SITEKEY")
ALLOWED_HOSTS = {urlparse(PAGE_URL).hostname} # set by you, not the model
MAX_SOLVES = 3 # CapSkip calls allowed per run
TOKEN_TTL = 100 # seconds a stored token may wait for its submit
_slots = threading.Semaphore(2) # at or below Max. Threads in CapSkip
_vault = {} # handle -> (token, page_url, born)
_used = [0]
_lock = threading.Lock()第 4 步:先检查主机,再做别的
发给 CapSkip 的页面地址是由模型选的,而智能体读到的页面上的文字可以把它推向别的地址。要在工具内部强制执行地址检查,因为提示里的规则可以被绕过去。把这段追加到 captcha_tools.py。
# captcha_tools.py, part 2: guards (appended to part 1)
def host_ok(url):
try:
u = urlparse(url)
u.port # raises ValueError on a malformed port
except ValueError:
return False
return (u.scheme in ("http", "https") and u.username is None
and u.hostname in ALLOWED_HOSTS)
def reset_run():
_vault.clear()
_used[0] = 0这个检查有意地去碰端口。只比较主机名的检查,会放过像 http://127.0.0.1:18512.evil.example/page 这样的地址,因为端口格式错误时,解析器仍然把 127.0.0.1 报告为主机。读取端口时这种情况会抛出 ValueError,函数随后就拒绝这个地址。它也会拒绝 file:、javascript: 和 ftp: 地址、空字符串,以及带有用户名的地址。它只比较主机,不比较端口。第 6 步的提交工具会对表单的 action 地址运行同样的检查。
第 5 步:识别工具
把这段追加到 captcha_tools.py。其中有三个决定很重要。工具会捕获每一个 CapSkip 异常,并返回一个小字典,因为从工具里逃出去的异常会结束整个智能体运行。它会统计自己发出的每一次 CapSkip 调用,失败的也算,这样一个不停请求的模型就无法让 CapSkip 一直忙下去。并且它把 token 存在一个字典里,只返回一个句柄。
# captcha_tools.py, part 3: the solve tool (appended to part 2)
def fail(error, retryable=False):
return {"ok": False, "error": error, "retryable": retryable}
@tool
def solve_recaptcha(
sitekey: str, page_url: str, kind: Literal["v2", "v3"] = "v2"
) -> dict:
"""Solve a reCAPTCHA on a page and return a handle for submit_form.
sitekey is the data-sitekey value on the page. page_url is the
full URL of the page that shows the CAPTCHA. The token itself is
kept out of the conversation; pass the returned handle on.
"""
if not host_ok(page_url):
return fail("host_not_allowed")
with _lock:
if _used[0] >= MAX_SOLVES:
return fail("solve_budget_used_up")
_used[0] += 1
try:
with _slots:
result = solver.recaptcha(sitekey, page_url, version=kind)
except TimeoutException:
return fail("timeout", retryable=True)
except NetworkException as exc:
log.warning("CapSkip unreachable: %s", exc)
return fail("capskip_unreachable")
except ApiException as exc:
log.warning("CapSkip refused the job: %s", exc)
unsolvable = "UNSOLVABLE" in str(exc)
return fail("unsolvable" if unsolvable else "capskip_rejected",
retryable=unsolvable)
except CapSkipError as exc:
return fail("bad_arguments: %s" % exc)
handle = "cap_" + secrets.token_hex(3)
_vault[handle] = (result["code"], page_url, time.monotonic())
return {"ok": True, "handle": handle, "valid_for_s": TOKEN_TTL}模型读的是文档字符串(docstring),所以要在那里写明每个参数是什么,以及 token 不会进入对话。没有 docstring 的工具会在定义时失败,抛出 ValueError: Function must have a docstring if description not provided。Literal 类型在 schema 里会变成枚举。超出它的值(例如 v4)永远到不了函数,CapSkip 也不会被调用。模型会看到一条以 Error invoking tool ‘solve_recaptcha’ 开头的消息,其中重复了它发送的参数,指出 kind: Input should be ‘v2’ or ‘v3’,并以 Please fix the error and try again 结尾。
工具抛出异常时会发生什么
这就是识别工具返回字典的原因。在模拟运行中,create_agent 只会把参数错误变成给模型的消息。其他所有异常都会原样从 agent.invoke 里抛出,结束这次运行。下面每一种都是从智能体里出来的,由模拟程序代替 CapSkip:
| 情况 | 从 agent.invoke 里逃出来的是什么 |
|---|---|
| 这次识别无法完成 | ApiException: ERROR_CAPTCHA_UNSOLVABLE |
| recaptchaTimeout 为 2 秒 | TimeoutException: timeout 2.0 exceeded,3.8 秒后 |
| CapSkip 没有在运行 | NetworkException: Max retries exceeded, WinError 10061,2.0 秒后 |
| 同时设置了 version v3 和 invisible 的调用 | ValidationException: invisible is only supported for reCAPTCHA v2. |
在工具上把 handle_tool_error 设为 True 在这里没有用。它只覆盖 LangChain 自己的 ToolException,而 CapSkip 的异常没有一个是它。超时之所以触发得晚,是因为 SDK 只在两次轮询之间检查它的时限。上面的工具改为返回一个小字典,其中 ok 设为 false,error 是 unsolvable 这样的名称,retryable 设为 true,模型可以据此行动。只有包含 UNSOLVABLE 的错误消息才算可重试。其他任何 API 错误(例如 API 密钥错误)都会以 capskip_rejected 返回,且不可重试。连接错误文本这类细节会写进日志,而不是给模型。如果你宁愿保留原始的工具,智能体库有两个钩子。ToolErrorMiddleware 接受一个 on_error 函数,它返回给模型的文本;如果该函数什么都不返回,异常仍然会结束运行。wrap_tool_call 装饰器让你自己写 try 和 except。两者在模拟运行中都让运行保持了存活。
第 6 步:提交工具
追加 captcha_tools.py 的最后一部分。提交工具会使用一个句柄。它把 token 从字典里弹出,所以一个句柄只能用一次,并且会拒绝比 TOKEN_TTL 更老的句柄。token 会过期,所以文件允许 100 秒,低于 Google 文档所说的两分钟。这样留给获取页面和提交的时间大约还剩 20 秒。提交工具没有地址参数,因为页面地址在识别 token 时就已经存下了。表单自己的 action 地址同样是页面文字,所以工具会对它运行同样的主机检查,并且不跟随重定向。模型无法选择 token 被提交到哪里。
# captcha_tools.py, part 4: the submit tool (appended to part 3)
@tool
def submit_form(handle: str, email: str) -> dict:
"""Submit the sign-up form with a handle from solve_recaptcha.
A handle works once. After a failed submit, solve again.
"""
entry = _vault.pop(handle, None) # pop: a handle is single use
if entry is None:
return fail("unknown_or_used_handle")
token, page_url, born = entry
if time.monotonic() - born > TOKEN_TTL:
return fail("handle_expired", retryable=True)
try:
session = requests.Session()
page = session.get(page_url, timeout=15, allow_redirects=False)
html = page.text
csrf = re.search(r'name="_csrf" value="([^"]+)"', html)
action = re.search(r'<form[^>]*action="([^"]*)"', html)
if not (csrf and action):
return fail("form_not_recognised")
target = urljoin(page_url, action.group(1))
if not host_ok(target): # the form action is page text too
return fail("host_not_allowed")
reply = session.post(
target,
data={"_csrf": csrf.group(1), "email": email,
"g-recaptcha-response": token},
headers={"Referer": page_url}, # requests sends none itself
timeout=15,
)
except requests.RequestException:
return fail("site_unreachable")
said = re.sub(r"<[^>]+>", " ", reply.text).strip()[:80]
return {"ok": reply.status_code == 200, "status": reply.status_code,
"page_says": said}Requests 不发送 Referer 头,而检查它的站点会返回 403。工具会设置一个。上面代码里的字段名是示例表单的字段名,不匹配的表单会返回 form_not_recognised。用同一个句柄第二次提交,会返回 unknown_or_used_handle,而模拟站点恰好只收到一次提交。工具会把页面应答的前 80 个字符交还给模型。页面上的文字是外部的话传到模型那里的一条途径,所以如果你不想这样,就只返回状态。把原始 token 提交两次,模拟站点返回 403,信息是 token already used,这是模拟程序的规则。参见 reCAPTCHA token 过期时间 了解真实 token 能用多久。
第 7 步:脚本化模型,不需要 API 密钥
把它保存为 fake_model.py。这是一个小型聊天模型,按顺序返回你写好的工具调用。智能体库会对模型调用 bind_tools,而这个模型接受该调用并忽略 schema。它能证明你的工具、它们的状态和智能体循环是可以工作的。它无法展示真实模型如何选择参数或读取错误。
# fake_model.py: a scripted chat model, no API key needed
# It calls the tools exactly as scripted, so the wiring runs for
# real. It says nothing about how a real model behaves.
from typing import Any, List
from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.messages import AIMessage
from langchain_core.outputs import ChatGeneration, ChatResult
def calls(*items) -> AIMessage:
"""One AI turn asking for tool calls: (name, args, id) each."""
return AIMessage(content="", tool_calls=[
{"name": n, "args": a, "id": i, "type": "tool_call"}
for n, a, i in items
])
def call(name: str, args: dict, cid: str) -> AIMessage:
return calls((name, args, cid))
class ScriptedChat(BaseChatModel):
script: List[Any] # AIMessages, or functions(messages) -> AIMessage
seen: List[Any] = [] # what each model call was sent
i: int = 0
@property
def _llm_type(self) -> str:
return "scripted"
def bind_tools(self, tools, **kwargs):
return self # the agent calls this; we ignore the schemas
def _generate(self, messages, stop=None, run_manager=None, **kw):
self.seen.append(list(messages))
item = self.script[self.i]
self.i += 1
msg = item(messages) if callable(item) else item
return ChatResult(generations=[ChatGeneration(message=msg)])第 8 步:构建智能体并运行
把它保存为 run_agent.py。没有 CAPTCHA_AGENT_MODEL 时,它使用脚本化模型。把这个变量设为提供方和模型名称后,它会通过 init_chat_model 构建一个真实模型。系统提示告诉模型,当工具说某个错误不可重试时就停下。用 .\.venv\Scripts\python run_agent.py 运行它。
# run_agent.py: wire the tools into an agent and run it
import json
import os
from langchain.agents import create_agent
from langchain.messages import AIMessage
from captcha_tools import PAGE_URL, SITEKEY, reset_run
from captcha_tools import solve_recaptcha, submit_form
SYSTEM = (
"Sign the user up on the page. Call solve_recaptcha, then "
"submit_form with the handle it returns. Never invent a handle. "
"If a tool says retryable is false, stop and report the error."
)
def build_model():
name = os.getenv("CAPTCHA_AGENT_MODEL") # "provider:model-id"
if name:
from langchain.chat_models import init_chat_model
return init_chat_model(name)
# No API key: a scripted model that calls the tools in order.
from fake_model import ScriptedChat, call
first = call("solve_recaptcha",
{"sitekey": SITEKEY, "page_url": PAGE_URL}, "c1")
def second(msgs): # read the handle out of the first tool result
handle = json.loads(msgs[-1].content)["handle"]
args = {"handle": handle, "email": "[email protected]"}
return call("submit_form", args, "c2")
return ScriptedChat(script=[first, second, AIMessage(content="Done.")])
agent = create_agent(build_model(), [solve_recaptcha, submit_form],
system_prompt=SYSTEM)
if __name__ == "__main__":
reset_run()
task = "Sign up [email protected] at %s. Sitekey: %s"
out = agent.invoke({"messages": [("user", task % (PAGE_URL, SITEKEY))]})
for m in out["messages"]:
print(m.type, "|", str(m.content or m.tool_calls)[:100])一次运行会打印对话:用户消息、一次识别调用、只包含句柄的工具结果、一次提交调用、页面的应答,以及 Done。在模拟页面上,工具结果里只有一个句柄和它的有效期,页面接受了一次提交。
使用真实模型
为你的模型安装对应提供方的包,并设置它的密钥和模型名称。工具里不需要改任何东西。这只在一个本地替身上运行过,该替身使用 OpenAI 和 Anthropic 的请求格式,用的是虚拟密钥,使用 langchain-openai 1.7.2 和 langchain-anthropic 1.7.6。没有调用任何真实的提供方。
# pip install langchain-anthropic (or langchain-openai for OpenAI) .\.venv\Scripts\python -m pip install langchain-anthropic $env:PAGE_URL = "https://your-site.example/signup" # your own page $env:SITEKEY = "YOUR_SITEKEY" $env:ANTHROPIC_API_KEY = "YOUR_PROVIDER_KEY" $env:CAPTCHA_AGENT_MODEL = "anthropic:YOUR_MODEL_ID" .\.venv\Scripts\python run_agent.py
这个替身显示了每个封装会发送什么。ChatOpenAI 把工具作为带有 name、description 和 parameters 的 function,发送到 /v1/chat/completions。ChatAnthropic 把它带着 name、description 和 input_schema,发送到 /v1/messages。两者默认都没有发送 tool_choice、strict 或 parallel_tool_calls。这个 schema 里 sitekey 和 page_url 是必填的,kind 是带默认值的枚举。打开 strict 后,LangChain 会改写 schema,让每个属性都成为必填,而提供方是否接受这样的 schema,没有测试过。
并行调用和异步工具
模型可以在一个回合里请求多个工具,LangChain 会同时运行它们。在模拟运行中,一次 2 秒的识别需要 3.8 秒,因为 SDK 在两次轮询之间分别等待 0.25、0.5、1 和 2 秒。当模拟程序有空间容纳三个任务时,一个回合里的三次识别也在 3.8 秒内完成。把模拟程序限制为一个线程时,模拟程序会把多出来的任务排队,同一个回合用了 7.8 秒。captcha_tools.py 里名为 _slots 的信号量限制这个进程同时打开多少个任务。在模拟运行中它没有节省任何时间,而且低于模拟程序线程数的上限只会多花时间:模拟程序为 2 个线程时,上限为 1 用了 11.3 秒,上限为 2 则是 7.5 秒。它的用途是防止一阵并行调用排入超过 CapSkip 的 Max. Threads 的任务(reCAPTCHA 默认为 10),因为等待超过 250 秒 Wait Timeout 的任务会被判为失败。这一部分没有测试过。把上限设为 2 或更高,最高不超过 Max. Threads。
普通的 def 工具是安全的默认选择。在 agent.ainvoke 下,LangChain 会在工作线程里运行它,所以事件循环保持空闲:在这台 PC 上,一次 2 秒的识别期间,循环从未停顿超过 0.02 秒。在内部调用同步 CapSkip 客户端的 async def 工具则相反。它在整整 3.7 秒里阻塞了循环。如果你想用异步,就用 AsyncCapSkip,它一次最多让循环停顿 0.3 秒,因为它为每个请求都新建一个 HTTP 客户端,每个大约 0.15 秒。把它保存为 async_tool.py。它不需要其他任何文件。一个工具可以同时服务两条路径:
# async_tool.py: one tool that works under .invoke and .ainvoke
import asyncio
import os
from capskip import AsyncCapSkip, CapSkip
from langchain_core.tools import StructuredTool
KEY = os.getenv("CAPSKIP_API_KEY", "capskip")
HOST = os.getenv("CAPSKIP_HOST", "127.0.0.1")
PORT = int(os.getenv("CAPSKIP_PORT", "8080"))
PAGE_URL = os.getenv("PAGE_URL", "https://example.com/page-with-recaptcha")
def solve_sync(sitekey: str, page_url: str) -> dict:
token = CapSkip(apiKey=KEY, host=HOST, port=PORT).recaptcha(
sitekey, page_url)["code"]
return {"ok": True, "token_chars": len(token)}
async def solve_async(sitekey: str, page_url: str) -> dict:
client = AsyncCapSkip(apiKey=KEY, host=HOST, port=PORT)
result = await client.recaptcha(sitekey, page_url)
return {"ok": True, "token_chars": len(result["code"])}
solve = StructuredTool.from_function(
func=solve_sync,
coroutine=solve_async,
name="solve_recaptcha",
description="Solve a reCAPTCHA v2 on a page; report the token size.",
)
if __name__ == "__main__":
args = {"sitekey": os.getenv("SITEKEY", "YOUR_SITEKEY"),
"page_url": PAGE_URL}
print(solve.invoke(args))
print(asyncio.run(solve.ainvoke(args)))只有异步的工具用 agent.invoke 调用会失败,报 NotImplementedError: StructuredTool does not support sync invocation。上面的两个函数避免了这一点。
拦住一直请求的模型
识别计数器让 CapSkip 最多只收到三个任务,但它并不会阻止模型继续请求。别的东西短时间内也拦不住它。在 create_agent 下,默认的递归上限是 9,999 步。一个永不停止的脚本化模型,在这台 PC 上不到一分钟就发出了大约 5,000 次模型调用,随后才出现 GraphRecursionError: Recursion limit of 9999 reached without hitting a stop condition。要设置你自己的上限,就在 agent.invoke 的 config 参数里传入 recursion_limit,值为 7。更干净的做法是在工具本身加限制,智能体库提供了这样的限制。把它保存为 limited_agent.py,放在 captcha_tools.py 和 fake_model.py 旁边(它会导入这两个文件),并用 .\.venv\Scripts\python limited_agent.py 运行。它的脚本化模型会一直请求识别,永不停止。
# limited_agent.py: stop a model that keeps asking for solves
from langchain.agents import create_agent
from langchain.agents.middleware import ToolCallLimitMiddleware
from captcha_tools import PAGE_URL, SITEKEY, reset_run
from captcha_tools import solve_recaptcha, submit_form
from fake_model import ScriptedChat, call
# A scripted model that never stops asking for a solve.
args = {"sitekey": SITEKEY, "page_url": PAGE_URL}
model = ScriptedChat(script=[
(lambda msgs: call("solve_recaptcha", args, "c%d" % len(msgs)))
] * 50)
agent = create_agent(
model,
[solve_recaptcha, submit_form],
middleware=[ToolCallLimitMiddleware(
tool_name="solve_recaptcha", run_limit=3, exit_behavior="end")],
)
if __name__ == "__main__":
reset_run()
out = agent.invoke({"messages": [("user", "go")]})
print(len(model.seen), "model calls")
print(out["messages"][-1].content)把 exit_behavior 设为 end 时,运行在第四次请求时停止,CapSkip 收到了三个任务。最后一条消息说 solve_recaptcha 的工具调用上限已达到,调用了 4 次,上限是 3 次。error 选项则改为抛出 ToolCallLimitExceededError。默认的 continue 会阻止该工具,并告诉模型不要再调用它,而忽略这一点的模型会一直继续下去,直到递归上限,所以请设置另外两个中的一个。
一个进程里的两次运行
captcha_tools.py 里的保管库是一个模块变量,所以同一进程里的两次智能体运行共用它,reset_run 会为两者一起清空它。一次只运行一个时没有问题。对于并发运行,要让保管库按运行区分。类型为 ToolRuntime 的工具参数由 LangChain 填入,不会出现在模型看到的 schema 里。给每次 invoke 调用传一个 context 对象,每次运行就有了自己的保管库。把它保存为 runtime_tools.py,放在 fake_model.py 旁边(它会导入该文件),并用 .\.venv\Scripts\python runtime_tools.py 运行:
# runtime_tools.py: a per-run vault through ToolRuntime (hidden arg)
import json
import os
from dataclasses import dataclass, field
from capskip import CapSkip
from langchain.agents import create_agent
from langchain.messages import AIMessage
from langchain.tools import ToolRuntime, tool
from langchain_core.utils.function_calling import convert_to_openai_tool
from fake_model import ScriptedChat, call
solver = CapSkip(
apiKey=os.getenv("CAPSKIP_API_KEY", "capskip"),
host=os.getenv("CAPSKIP_HOST", "127.0.0.1"),
port=int(os.getenv("CAPSKIP_PORT", "8080")),
)
PAGE_URL = os.getenv("PAGE_URL", "https://example.com/page-with-recaptcha")
@dataclass
class RunState: # one instance per agent.invoke(..., context=...)
vault: dict = field(default_factory=dict) # handle -> token
@tool
def solve_recaptcha(
sitekey: str, page_url: str, runtime: ToolRuntime[RunState]
) -> dict:
"""Solve a reCAPTCHA and return a handle for use_token."""
token = solver.recaptcha(sitekey, page_url)["code"]
handle = "cap_%d" % (len(runtime.context.vault) + 1)
runtime.context.vault[handle] = token
return {"ok": True, "handle": handle}
@tool
def use_token(handle: str, runtime: ToolRuntime[RunState]) -> dict:
"""Spend a handle from solve_recaptcha. It works once."""
token = runtime.context.vault.pop(handle, None)
if token is None:
return {"ok": False, "error": "unknown_or_used_handle"}
return {"ok": True, "token_chars": len(token)}
if __name__ == "__main__":
# the model-facing schema has no "runtime" property
schema = convert_to_openai_tool(solve_recaptcha)
print(list(schema["function"]["parameters"]["properties"]))
args = {"sitekey": os.getenv("SITEKEY", "YOUR_SITEKEY"),
"page_url": PAGE_URL}
def second(msgs): # read the handle out of the first tool result
handle = json.loads(msgs[-1].content)["handle"]
return call("use_token", {"handle": handle}, "c2")
model = ScriptedChat(script=[
call("solve_recaptcha", args, "c1"), second, AIMessage(content="Done."),
])
agent = create_agent(model, [solve_recaptcha, use_token],
context_schema=RunState)
run = RunState()
out = agent.invoke({"messages": [("user", "go")]}, context=run)
print(out["messages"][-2].content, "| vault left:", run.vault)它打印的第一行列出模型看到的两个属性,sitekey 和 page_url,没有 runtime。运行之后,保管库是空的。captcha_tools.py 里的识别计数器也是模块变量,而 runtime_tools.py 为了简短,省略了主机检查、预算、时间限制和错误处理,所以如果你复制它,请把这些带过去。这样构建的工具只能在智能体内部工作。captcha_tools.py 那一对工具也原样运行在手工构建的 LangGraph 图里,使用 ToolNode 和 tools_condition,在 langgraph 1.2.16 上没有任何警告。ToolRuntime 版本需要在图上设置 context_schema=RunState,并在 invoke 上传入 context 参数。
对另一台机器上的 CapSkip 运行智能体
智能体和识别工具不必共用一台 PC。在连接设置里把 CapSkip 切换到 Server 模式,它就会监听你的网络地址或公网 IP。建议使用静态公网 IP,以便稳定地远程访问。然后设置文件已经在读取的那三个变量。识别工具仍然是你自己的 Windows 机器,识别也仍然不计量。
# CapSkip on another Windows PC, in Server mode: set these first $env:PAGE_URL = "https://your-site.example/signup" # your own page $env:SITEKEY = "YOUR_SITEKEY" $env:CAPSKIP_HOST = "192.168.1.50" $env:CAPSKIP_PORT = "8080" $env:CAPSKIP_API_KEY = "YOUR_API_KEY" # only if API key validation is on .\.venv\Scripts\python run_agent.py
有三点要注意。SDK 通过普通 HTTP 与 CapSkip 通信,所以 API 密钥是以明文传输的。把这条链路放在专用网络或 VPN 里,并开启 API key validation。Server 模式启动时,CapSkip 会向所有地址开放端口,所以要缩小范围,办法是为你不预期的地址添加一条 Windows Firewall 阻止规则。CAPSKIP_HOST 里只放地址,并且使用 IP 地址:在这台 PC 上,被拒绝的连接到 127.0.0.1 用了大约 2 秒,到 localhost 用了大约 4 秒。以 http:// 开头的值会失败,抛出一个 NetworkException,说无法解析主机 http。末尾带端口的值会失败,消息说主机或端口无效。本指南没有运行远程设置,只读取了这些变量。
LangChain 验证码工具的常见错误
| 你所看到的 | 原因 | 修复 |
|---|---|---|
| ImportError: cannot import name ‘AgentExecutor’ from ‘langchain.agents’ | 一个旧教程 | 使用 create_agent |
| LangGraphDeprecatedSinceV10 警告 | create_react_agent 是从 langgraph.prebuilt 导入的 | 从 langchain.agents 导入 create_agent |
| ValueError: Function must have a docstring | 工具没有 docstring,也没有描述 | 添加 docstring |
| Error invoking tool,随后是:Input should be ‘v2’ or ‘v3’ | 模型发送了 Literal 之外的值 | 不用改。真实的模型应该会读到它并改正这个值,这一点没有测试过 |
| NotImplementedError: StructuredTool does not support sync invocation | 只有异步的工具被 invoke 调用 | 也给工具提供一个同步函数 |
| agent.invoke 以一个 CapSkip 异常结束 | 工具让它逃出来了 | 在工具里捕获它,并返回一个字典 |
| capskip_unreachable | CapSkip 没有运行,或者端口错了。被拒绝的本地端口大约 2 秒后失败,而一台关机的远程 PC 用了大约 21 秒,因为同步客户端自己不设置 HTTP 超时 | 启动 CapSkip,并检查 CAPSKIP_HOST 和 CAPSKIP_PORT |
| capskip_rejected | CapSkip 因为验证码无法识别之外的原因拒绝了任务,例如 API 密钥错误 | 读日志里的那一行,修正密钥或参数 |
| host_not_allowed | 页面地址不在 PAGE_URL 的主机上 | 把 PAGE_URL 设为你的页面 |
| solve_budget_used_up | 本次运行已达到 MAX_SOLVES | 调高它,或者查明识别为什么失败 |
| unknown_or_used_handle 或 handle_expired | 句柄已经用掉,或者等待超过了 TOKEN_TTL | 重新识别 |
| form_not_recognised | 页面没有名为 _csrf 的字段,或者没有表单 action | 修改 submit_form 里的字段名 |
| GraphRecursionError | 模型始终没有给出最终答案 | 设置 recursion_limit 和工具调用上限 |
| 提到 Referer 的 403 | 站点会检查它,而请求没有发送 | 在 submit_form 里保留 Referer 头 |
常见问题
试用这个需要语言模型的 API 密钥吗?
运行这些工具不需要。由脚本化模型发出调用,而 CapSkip 不需要密钥,除非你开启了 API key validation。要看真实模型使用这些工具,请设置提供方的密钥和 CAPTCHA_AGENT_MODEL。真实模型在这些工具上的表现,本指南没有测试过。
智能体应该改为驱动浏览器吗?
这取决于页面。这个工具适合可以直接提交的普通表单。如果验证码只能在真实的浏览器会话里工作,就用你自己的代码驱动浏览器,并在那里把 token 交给页面,而不要经过模型。 Browser Use 指南 演示了一个自己运行浏览器的智能体。
如果我的智能体是 Claude Code 或其他 MCP 客户端呢?
那你就不用自己写这个工具了。CapSkip 为此提供了 MCP 服务器, MCP 验证码识别页面 里有详细说明。
智能体能运行在托管平台上吗?
运行你的 Python 的托管平台,可以调用你在自己 Windows PC 上运行的 CapSkip,识别工具不会搬到平台上。把 CapSkip 设为 Server 模式,给这台机器一个静态公网 IP,并设置 CAPSKIP_HOST。这样密钥和页面地址会通过普通 HTTP 以明文传输,所以最好用 VPN 或专用网络,并为其他地址添加一条 Windows Firewall 阻止规则。本指南没有测试任何托管平台。
简短版结论
LangChain 验证码工具应该返回句柄而不是 token,捕获每一个 CapSkip 异常并返回一个小字典,在函数内部检查页面地址,统计识别次数,并放在工具调用上限之后。把 token 保存在你这一侧的字典里,并在第二个工具里使用它。除非需要异步,否则使用普通的 def 工具。
- Python 包能处理的所有验证码类型: Python 验证码识别页面.
- 复选框识别工具是如何工作的: reCAPTCHA v2 识别页面.
- 自己运行浏览器的智能体: 在 Browser Use 中识别验证码.
- 适用于 Claude Code 和其他 MCP 客户端: MCP 验证码识别工具.
一个不断重试直到成功的智能体,只有在每次重试都免费时才负担得起,而把 验证码识别工具 放在你自己的 Windows 机器上,每次重试只花几秒钟,别的什么都不花。
