本节摘要:本节处理会话基建的第一块:登录态。多数真实任务的第一个动作是登录,而登录恰恰是最贵、最敏感、最不适合重复做的交互——重复登录多付 token、多冒一次凭证暴露风险、还可能触发站点风控。方案分三层讲:其一,登录态的持久化,Playwright 的 storageState 机制把 Cookie 与 storage 导出成文件,登录一次、处处复用;其二,会话复用的工程形态,任务带状态启动(storage_state 参数)、状态文件的生命周期管理(谁能读、多久刷新一次);其三,凭证注入的安全形态——环境变量起步、密管系统(vault 类)进阶、第 2.2 节的敏感数据占位符机制在 Agent 循环内的落地,硬编码的三宗罪在这里完整重演。最后是会话过期的检测与刷新:过期是第 6.2 节权限类失败的头号根因,检测信号表加自动重登策略。示例打在靶场登录页上,Python 与 JS 双语。
把「每次任务都重新登录」当成默认方案,会在三个方向上同时失血:
| 方向 | 代价 |
|---|---|
| 成本 | 每次任务多出数步模型调用(找到输入框、填写、提交、等待) |
| 风险 | 凭证进入交互流程的次数越多,暴露面越大(日志、轨迹、提示词) |
| 稳定性 | 登录页是风控最密集的页面,重复登录是最容易被识别为异常的行为模式 |
结论:登录应该被做成缓存——认真做一次,把产物(Cookie 与 storage 状态)存下来,后续任务直接带着状态启动。这个思路与第 3.3 节固化一脉相承:高频且稳定的流程(登录多半是)值得从每步计费的路径上摘出来,区别只在固化的对象从「选择器」换成了「会话状态」。
Playwright 的 storageState 机制是这件事的标准答案:context 可以把当前的 Cookie 与 localStorage、sessionStorage 导出为 JSON 文件,新 context 创建时从这个文件恢复状态。双语示例(靶场侧用第 2.2 节的 demo_login.html):
# session_reuse.py —— 登录态持久化与复用(写法示意,以官方文档为准) import os from pathlib import Path from playwright.sync_api import sync_playwright STATE_FILE = Path("auth") / "demo_state.json" def login_once() -> None: """首次登录:完成认证并导出 storageState""" with sync_playwright() as p: browser = p.chromium.launch() context = browser.new_context() page = context.new_page() page.goto("http://127.0.0.1:8080/demo_login.html") page.get_by_label("用户名").fill(os.environ["DEMO_USER"]) page.get_by_label("密码").fill(os.environ["DEMO_PASS"]) page.get_by_role("button", name="登录").click() page.wait_for_selector("text=欢迎回来") STATE_FILE.parent.mkdir(exist_ok=True) context.storage_state(path=str(STATE_FILE)) browser.close() def run_task_with_session() -> None: """会话复用:带着状态直接进入登录后页面""" with sync_playwright() as p: browser = p.chromium.launch() context = browser.new_context(storage_state=str(STATE_FILE)) page = context.new_page() page.goto("http://127.0.0.1:8080/demo_todo.html") print("带会话访问:", page.title()) browser.close() if not STATE_FILE.exists(): login_once() run_task_with_session()
// session_reuse.mjs —— 登录态持久化与复用(写法示意,以官方文档为准) import { mkdirSync, existsSync } from "node:fs"; import { chromium } from "playwright"; const STATE_FILE = "auth/demo_state.json"; /** 首次登录:完成认证并导出 storageState @param {import("playwright").Browser} browser 浏览器实例 */ async function loginOnce(browser) { const context = await browser.newContext(); const page = await context.newPage(); await page.goto("http://127.0.0.1:8080/demo_login.html"); await page.getByLabel("用户名").fill(process.env.DEMO_USER ?? "demo"); await page.getByLabel("密码").fill(process.env.DEMO_PASS ?? ""); await page.getByRole("button", { name: "登录" }).click(); await page.waitForSelector("text=欢迎回来"); mkdirSync("auth", { recursive: true }); await context.storageState({ path: STATE_FILE }); await context.close(); } const browser = await chromium.launch(); if (!existsSync(STATE_FILE)) { await loginOnce(browser); } const context = await browser.newContext({ storageState: STATE_FILE }); // 会话复用 const page = await context.newPage(); await page.goto("http://127.0.0.1:8080/demo_todo.html"); console.log("带会话访问:", await page.title()); await browser.close();
读这两个示例的三个要点:其一,登录发生在独立的短生命周期 context 里,任务运行在另一个 context——登录的凭证输入过程与任务执行过程隔离,轨迹日志各归各;其二,状态文件是一份敏感物(它就是登录态本身),权限收紧(仅运行用户可读)、不入版本库、随会话周期销毁;其三,多账号场景按账号分文件(demo_state_alice.json、demo_state_bob.json),账号与会话一一对应,为第 10 章的并发隔离打底。
第 2.2 节讲过敏感数据机制(占位符、晚绑定、脱敏),那是 Agent 循环内的防线;循环外,凭证从哪来到代码,还有一条台阶:
| 台阶 | 形态 | 适用 | 说明 |
|---|---|---|---|
| 起步 | 环境变量 | 本地开发、单机部署 | 第 0.2 节的密钥纪律,最低成本 |
| 进阶 | 密管系统(vault 类) | 团队与生产环境 | 集中管理、审计取用、动态轮换 |
| 循环内 | 敏感数据占位符 | 任务文本含凭据时 | 第 2.2 节机制:真值晚绑定、日志脱敏 |
硬编码的三宗罪在第 2.2 节已经数过(进提示词、进轨迹、进日志),这里补第四宗:进版本库——写在代码里的凭证会随每一次提交永久留在历史里,轮换之前历史就是一份泄露清单。工程底线一句话:代码仓库里可以有的是占位符与变量名,真值只存在于环境变量与密管系统,两者都支持第 11.3 节要讲的轮换流程。
台阶之间的迁移成本很低(都是「读一个名字、拿一个值」),建议从环境变量起步,团队规模到了再上密管——但无论哪个台阶,轮换能力都应该在第一天就具备:凭证是会泄露的资产,能不能快速换,比会不会泄露更能决定事故的大小。
storageState 不是永动机——Cookie 会到期、服务端会踢会话。过期是第 6.2 节权限类失败(401、403、跳回登录页)的头号根因,值得一张信号表:
| 信号 | 检测方式 | 处置 |
|---|---|---|
| 跳回登录页 | URL 或页面出现登录表单 | 标记会话失效,触发重登 |
| 401/403 响应 | 网络层监听(第 4.3 节 DevTools 视角) | 同上,且应区分过期与权限不足 |
| 接口返回会话失效码 | 业务响应体 | 按业务码表处置 |
| 页面提示重新登录 | 文本匹配 | 同上 |
刷新策略分被动与主动:被动刷新是任务失败时按信号表判定后重登一次再重跑(配合第 6.2 节回退链,会话失效归入「值得救」的类别);主动刷新是按会话的已知有效期提前重登(比如每二十四小时刷新一次状态文件)。建议被动为主、主动为辅——主动刷新在会话本就有效的日子里是纯浪费,被动刷新只在真正失效时付成本。
| 坑 | 现象 | 排查与处理 |
|---|---|---|
| 状态文件入库 | 仓库里出现 demo_state.json | 加忽略规则;已入库立即改密并清理历史 |
| 并发同账号互踢 | 多任务共用一个会话被服务端踢下线 | 按任务发独立 context;多账号分文件轮转 |
| 过期误诊为漂移 | 反复重定位仍失败 | 先查第 6.2 节信号表,401 类先重登 |
| 凭证进日志 | 日志里出现明文密码 | 走占位符机制;日志字段白名单化 |
| 状态文件无主 | 没人知道哪个文件对应哪个账号 | 命名规范加台账,账号、文件、刷新时间三列 |
状态管好了,下一个问题是环境:Agent 的浏览器要跑在什么上面、能碰什么、出事怎么兜住。下一节给浏览器画边界——沙箱隔离,并认识自托管形态的代表 steel-browser。