用Python复刻ClaudeDuet:主动式Agent四件套,定时晨报、后台巡检、危险动作审批(附完整可运行代码)

昨天(10 月 22 日),Anthropic 发布了主动式智能体 Claude Duet :由 Opus 5.5 驱动,每天早上自动生成晨报、在后台巡检你的工作系统、主动执行任务------而它的关键安全设计只有一句话:不可逆动作需用户批准。官方同时宣布接入 6000 余个连接器。

同一天,2026 科大讯飞全球 1024 开发者节主论坛在北京国家会议中心举行,讯飞星火 X2.6 发布,主打「云+端」协同:敏感数据留在端侧处理、脱敏后再上云,破解「怕泄密、怕太贵」两道难题。

大洋两岸,两家公司在 24 小时内说了同一件事:AI 的下一站,是从「你问我答」变成「我主动干活」------但每个危险动作,都要过一道门。

对开发者来说,这引出一个非常实际的问题:Claude Duet 暂时用不上、连接器也不覆盖国内业务,能不能用 Python 自己搭一个「主动式 Agent」?答案是:调度 + 幂等 + 审批 + 核验,四件套。

下面每一块代码都是同一个文件的一部分(agent.py),按坑拆开讲,拼起来就能跑。

环境:Python 3.10+,依赖 pip install apscheduler pytz requests。

本文在 Python 3.12.2 + APScheduler 3.10.4 实测:本地 26 项断言 + 6 项真实接口调用,全部通过,无一失败。

LLM 走 OpenAI 兼容协议,本文默认 DeepSeek:LLM_BASE=https://api.deepseek.com/v1、LLM_MODEL=deepseek-v4-pro。核验走 HallucC 的真实公开接口。

坑 1:晨报定在 8:30,为什么下午 4 点半才发------时区的静默错误

报错现场(两种形态,一种炸、一种更阴险):

复制代码
# 形态一:启动即报错
ValueError: Time zone offset does not exist

# 形态二:不报错,但时间全错------
# 服务器时区是 UTC,你的「早上 8:30」变成了北京时间 16:30

根因:APScheduler 不显式指定时区时,会沿用系统时区。容器和云服务器的系统时区十有八九是 UTC------cron 触发器按 UTC 算,你写 hour=8 就是 UTC 8 点,北京时间下午 4 点半才触发。定时任务「查不出来为什么没跑」的第一嫌疑犯,永远是时区。

修复:调度器创建时显式钉死时区,并给任务保命参数:

python 复制代码
from apscheduler.schedulers.blocking import BlockingScheduler
from apscheduler.triggers.cron import CronTrigger
import pytz

TZ = pytz.timezone('Asia/Shanghai')


def build_scheduler() -> BlockingScheduler:
    scheduler = BlockingScheduler(timezone=TZ)
    scheduler.add_job(morning_brief, CronTrigger(hour=8, minute=30, timezone=TZ),
                      id='brief', max_instances=1, coalesce=True)
    scheduler.add_job(patrol, 'interval', minutes=15, timezone=TZ,
                      id='patrol', max_instances=1, coalesce=True)
    return scheduler


def next_fire(trigger, prev=None, now_dt=None):
    """触发器自身的下一次触发时刻------start() 之前也能算,且自带时区。

    注意 APScheduler 3.x 的签名是 get_next_fire_time(previous_fire_time, now),
    now 是必填参数,少传会 TypeError: missing 1 required positional argument: 'now'。
    """
    return trigger.get_next_fire_time(prev, now_dt or now())

max_instances=1 和 coalesce=True 是巡检任务的保命参数:前者防同一任务堆叠并发,后者把积压的多次触发合并成一次------网络卡 5 分钟、巡检堆积 20 次的惨案,这两个参数直接免疫。

这里有个容易踩的坑 :不要照网上教程那样 sched.get_job('brief').next_run_time 去验证时刻,会直接炸:

复制代码
AttributeError: 'Job' object has no attribute 'next_run_time'

原因是 add_job() 在调度器还没 start() 时,只把 job 塞进 _pending_jobs(日志里那句 "Adding job tentatively -- it will be properly scheduled when the scheduler starts"),next_run_time 这个槽位要等 start() 才填。调度本身是对的,是验证方式不对------想看触发时刻,问触发器自己。

实测确认:next_fire(brief_trigger) = 2026-10-24 08:30:00+08:00,时区 Asia/Shanghai;巡检连续两次触发相差正好 900 秒。

坑 2:进程一重启,晨报又发了一遍------主动式任务必须幂等

报错现场 :晨报发完进程崩了,supervisor 自动拉起,morning_brief() 再跑一遍------全组收到两份晨报。更糟的是巡检告警:重启循环里告警每次都发,工作群被机器人刷屏。

根因:「定时触发」和「执行过没有」是两回事。被动式 Agent 天然幂等(你问一次它答一次),主动式 Agent 必须自己记住「这个任务今天做过了没有」。

修复 :SQLite 做原子幂等锁,UNIQUE 约束天然防并发,INSERT OR IGNORE 加 rowcount 一行完成「检查 + 占坑」的原子操作:

python 复制代码
import datetime
import sqlite3

DB = 'agent_tasks.db'


def init_db():
    with sqlite3.connect(DB) as conn:
        conn.execute('''CREATE TABLE IF NOT EXISTS task_log (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            task TEXT NOT NULL,
            idem_key TEXT UNIQUE NOT NULL,
            status TEXT DEFAULT 'done',
            created_at TEXT DEFAULT (datetime('now','localtime'))
        )''')
        conn.commit()


def acquire_lock(task: str, idem_key: str) -> bool:
    """原子抢占:INSERT 成功=拿到执行权;UNIQUE 冲突=已执行过。"""
    conn = sqlite3.connect(DB)
    try:
        cur = conn.execute('INSERT OR IGNORE INTO task_log(task, idem_key) VALUES(?,?)',
                           (task, idem_key))
        conn.commit()
        return cur.rowcount == 1
    finally:
        conn.close()          # with sqlite3.connect() 只 commit/rollback,不负责 close


def now() -> datetime.datetime:
    """统一取 Asia/Shanghai 时间,别用裸的 datetime.now()。"""
    return datetime.datetime.now(TZ)


def brief_key() -> str:
    return f'brief-{now().date().isoformat()}'


def patrol_key() -> str:
    return f"patrol-{now().strftime('%Y%m%d%H%M')}"


def mark_failed(task: str, idem_key: str) -> None:
    """执行失败时释放幂等锁,让下一个窗口有机会重试。

    否则「抢到锁 -> 中途崩了」会变成「今天的任务永久跳过」,
    而且不会有任何提示------比静默放行更糟。
    """
    conn = sqlite3.connect(DB)
    try:
        conn.execute("DELETE FROM task_log WHERE task=? AND idem_key=? AND status='done'",
                     (task, idem_key))
        conn.commit()
    finally:
        conn.close()

两个任务这样用:

python 复制代码
def morning_brief():
    key = brief_key()
    if not acquire_lock('morning_brief', key):
        return                          # 幂等锁:今天已生成过,直接跳过
    try:
        brief = agent_run('汇总今日行业要闻与团队待办,生成 300 字晨报')
        if not isinstance(brief, str):
            raise RuntimeError(f'LLM 返回了非文本结果: {type(brief).__name__}')
    except Exception as exc:
        mark_failed('morning_brief', key)   # 抢了锁却没跑成 -> 释放,允许重试
        notify(f'[晨报生成失败] {exc.__class__.__name__}: {exc}')
        return
    notify(brief)


def patrol():
    """每 15 分钟巡检:Agent 查数据源,仅触发阈值时才通知"""
    key = patrol_key()
    if not acquire_lock('patrol', key):
        return
    try:
        result = agent_run('巡检数据源,异常时输出 {"level":"alert","summary":"..."}',
                           output_json=True)
    except Exception:
        mark_failed('patrol', key)
        return
    if not isinstance(result, dict):
        mark_failed('patrol', key)
        return
    if result.get('level') == 'alert':
        notify(f"[巡检告警] {result.get('summary', '')}")

注意幂等键的粒度:晨报到「天」(brief-2026-10-23),巡检到「分钟」。重启多少次,晨报一天最多一份;巡检窗口过了就翻篇,不会补发旧告警。

还有一个自相矛盾的隐患 :坑 1 花了大力气钉死调度时区,幂等键却别用裸的 datetime.date.today() / datetime.datetime.now()------那跟着本机时区走。同一个 UTC 容器里,brief- 键在 UTC 零点(北京 08:00)翻篇,而不是北京零点。上面统一走 now() 就是为了消掉这个不一致。

还有一个必须处理的失败态 :先抢锁再干活,锁就已经占了。如果 agent_run 这时抛异常(LLM 挂了、网络断了),没有 mark_failed() 的话,这个键当天的所有后续触发都会被「已执行过」拦掉------任务永久跳过,而且日志里什么都没有。本文实测过这个场景:LLM 端点不可达时抛 ConnectionError,锁被释放,下一次调用重试而不是静默跳过。

实测:20 个线程并发抢同一把锁,只有 1 个线程拿到执行权------这就是「靠数据库原子写,而不是靠自己的 if 判断」的价值。

坑 3:Agent 要发邮件、删文件------复刻 Claude Duet 的「审批门」

这是 Claude Duet 最值得抄的设计:不可逆动作,需用户批准。邮件发出去了收不回、文件删掉了难恢复------这类动作不能进 Agent 的自主执行清单。

修复 :所有动作走统一出口 guarded_call(),低危直接执行,高危转工单:

python 复制代码
import json
import os
import uuid

PENDING = 'pending_actions.jsonl'

RISK_LEVELS = {'query_data': 'low', 'generate_report': 'low',
               'send_email': 'high', 'delete_file': 'high', 'update_order': 'high'}

HANDLERS = {}


def handler(name):
    """装饰器:登记 Agent 可调用的动作实现。"""
    def deco(fn):
        HANDLERS[name] = fn
        return fn
    return deco


@handler('query_data')
def _query_data():
    return {'rows': [], 'note': '接你自己的数据源'}


@handler('generate_report')
def _generate_report(title=''):
    return {'report': f'报告:{title}'}


@handler('send_email')
def _send_email(to='', subject='', body=''):
    return {'sent': False, 'to': to, 'note': '演示:不真的发邮件'}


@handler('delete_file')
def _delete_file(path=''):
    return {'deleted': False, 'path': path, 'note': '演示:不真的删'}


@handler('update_order')
def _update_order(order_id=None, status=''):
    return {'updated': False, 'order_id': order_id}


def guarded_call(agent_id: str, tool: str, args: dict):
    """Agent 所有动作的统一出口:低危直接执行,高危转人工审批。"""
    if RISK_LEVELS.get(tool, 'high') == 'high':     # 未登记默认高危
        return request_approval(agent_id, tool, args)
    if tool not in HANDLERS:                         # 登记成 low 却没实现 -> 别崩
        return {'error': f'no handler for low-risk tool {tool!r}'}
    return HANDLERS[tool](**args)


def request_approval(agent_id: str, tool: str, args: dict) -> str:
    """高危动作不执行,落盘成待审批工单并推送提醒,返回工单号"""
    ticket = uuid.uuid4().hex[:8]
    record = {'id': ticket, 'agent': agent_id, 'tool': tool, 'args': args,
              'status': 'pending', 'created_at': now().isoformat(timespec='seconds')}
    with open(PENDING, 'a', encoding='utf-8') as f:
        f.write(json.dumps(record, ensure_ascii=False) + '\n')
    notify(f'【待审批 {ticket}】{tool} {args}')
    return ticket


def review(ticket: str, approve: bool) -> str:
    """人工审批入口:只有 pending 状态才真正放行,状态全程留痕。"""
    if not os.path.exists(PENDING):
        return 'not_found'
    with open(PENDING, encoding='utf-8') as f:
        entries = [json.loads(line) for line in f if line.strip()]
    outcome = 'not_found'
    for e in entries:
        if e['id'] != ticket:
            continue
        if e['status'] != 'pending':
            outcome = f"already_{e['status']}"        # 防重复审批
            break
        if approve:
            if e['tool'] not in HANDLERS:
                e['status'] = 'failed'                # 放行但没人执行 -> 别假装成功
                outcome = e['status']
                break
            HANDLERS[e['tool']](**e['args'])
            e['status'] = 'approved'
        else:
            e['status'] = 'rejected'
        e['reviewed_at'] = now().isoformat(timespec='seconds')
        outcome = e['status']
        break
    # 原子替换:先写临时文件再 rename,进程中途挂掉不会把审计日志截断
    tmp = PENDING + '.tmp'
    with open(tmp, 'w', encoding='utf-8') as f:
        for e in entries:
            f.write(json.dumps(e, ensure_ascii=False) + '\n')
    os.replace(tmp, PENDING)
    return outcome

review() 设计了三重防线:找不到工单返回 not_found;已审批过的返回 already_approved,防止「审批通过」的按钮被点两次、动作执行两遍;只有 pending 状态才真正放行。

工单文件本身就是审计日志------谁的动作、什么参数、何时批准,全部留痕。这与 Claude Duet「不可逆动作需用户批准」、以及讯飞 AIPC 在政企场景强调的「用户身份识别、权限管理、运行日志审计」思路完全一致。

一个容易漏掉的细节 :HANDLERS 得有定义。guarded_call 的低危分支和 review 的批准分支都要调它,不登记任何实现的话,两条路径都会 NameError: name 'HANDLERS' is not defined------注意拒绝路径不碰它,所以 rejected / already_rejected 还能正常返回,问题会藏得更深。

实测:批准后返回 approved,再点一次返回 already_approved 且动作不重复执行;拒绝后返回 rejected,事后想补批被 already_rejected 拦住;连未登记的工具名都会默认走高危通道。

坑 4:晨报群发前,没人核对过里面的数据------出口安检

症状:四件套跑通了,每天 8:30 准时发晨报,全组都在看。某天同事问:「晨报里说竞品昨天下架了,我怎么看还在卖?」------那条「要闻」是模型编的。

被动式对话里,错的答案只有你一个人看到;主动式晨报里,错的答案发给了整个群。 传播半径变了,核验责任就变了。

修复 :给晨报出口加一道安检。这里用的是 HallucC 的真实公开接口,先看清接口再写代码------这类接口的字段名是最容易猜错的地方:

python 复制代码
AIHCC_BASE = os.environ.get('AIHCC_BASE') or 'https://aihcc.cloud/api'

MAX_CHARS = 50_000     # /detect 的 text 上限,超出部分会被服务端截断


def verify_brief(text: str, strict: bool = False, domain: str = 'general') -> dict:
    """调 HallucC /detect 做逐句事实核验。

    响应里只有结构化判定,没有现成的「标注文本」------标注要自己渲染(见 compose_report)。
    """
    _, key = hallucc_key()
    resp = requests.post(f'{AIHCC_BASE}/detect',
                         headers={'Authorization': f'Bearer {key}'},
                         json={'text': text[:MAX_CHARS], 'strict': strict,
                               'domain': domain},
                         timeout=120)
    resp.raise_for_status()
    data = resp.json()
    # 200 但 body 是错误结构(额度/内部错)时,别当成「核验通过」
    if not isinstance(data.get('summary'), dict) or not data.get('claims'):
        raise RuntimeError(f"/detect 返回了非检测结构: "
                           f"{json.dumps(data, ensure_ascii=False)[:200]}")
    return data

几个字段名是实打实测出来的,别照抄别处的教程:

你以为的 实际是
Base URL https://api.aihcc.cloud https://aihcc.cloud/api(前者域名压根不存在,DNS 解析失败)
端点 /v1/detect/text POST /detect
请求带 lang: 'zh' 没有 lang 参数,只有 text / strict / domain / knowledge_id
响应有 annotated_text 没有 ,只有 summary + claims[] 结构化判定
存疑数 summary.doubtful summary.red(存疑)、summary.yellow(疑似)、summary.green(可信)、summary.hallucination_risk

响应长这样:

json 复制代码
{"detection_id": 1, "input": "...",
 "summary": {"total": 3, "red": 1, "yellow": 1, "green": 1, "hallucination_risk": 0.67},
 "claims": [
   {"claim": {"text": "...", "type": "factual"},
    "verification": {"status": "refuted", "confidence": 0.9,
                     "sources": [{"title": "...", "url": "...", "snippet": "..."}]},
    "correction": {"corrected": "..."}}],
 "quota": {"remaining": 1999}}

verification.status 只有三个值:supported(支持)/ refuted(反驳)/ unverified(无法核实);correction.corrected 只在 refuted 时有。鉴权用 Authorization: Bearer hc_xxxx,POST /register 注册即得、只返回一次。

接口本身不给你「标注好的文本」,逐句标注得自己渲染:

python 复制代码
MARKS = {'supported': '🟢', 'refuted': '🔴', 'unverified': '🟡'}


def compose_report(report: dict, raw: str) -> str:
    """把 /detect 响应渲染成可读晨报(接口本身不返回标注文本)"""
    s = report.get('summary') or {}
    head = (f"晨报核验:共 {s.get('total', 0)} 句 | "
            f"🟢可信 {s.get('green', 0)} 🟡疑似 {s.get('yellow', 0)} "
            f"🔴存疑 {s.get('red', 0)} | 幻觉风险 {s.get('hallucination_risk', 0):.2f}")
    lines = [head, '', raw, '', '------ 逐句核验 ------']
    for c in report.get('claims') or []:
        v = c.get('verification') or {}
        mark = MARKS.get(v.get('status'), '⚪')
        line = f"{mark} {c.get('claim', {}).get('text', '')}" \
               f"(把握 {v.get('confidence', 0):.0%})"
        srcs = v.get('sources') or []
        if srcs:
            line += f" 依据:{srcs[0].get('title', '')}"
        fix = (c.get('correction') or {}).get('corrected')
        if fix:
            line += f"\n  建议改为:{fix}"
        lines.append(line)
    return '\n'.join(lines)

挂进晨报流程,而且一定要处理「核验服务挂了」这种失败态------否则安检反而成了单点故障:

python 复制代码
def morning_brief():
    key = brief_key()
    if not acquire_lock('morning_brief', key):
        return
    try:
        brief = agent_run('汇总今日行业要闻与团队待办,生成 300 字晨报')
        if not isinstance(brief, str):
            raise RuntimeError(f'LLM 返回了非文本结果: {type(brief).__name__}')
    except Exception as exc:
        mark_failed('morning_brief', key)   # 抢了锁却没跑成 -> 释放,允许重试
        notify(f'[晨报生成失败] {exc.__class__.__name__}: {exc}')
        return

    try:
        report = verify_brief(brief)
        if (report.get('summary') or {}).get('total', 0) == 0:
            # 一句话都没抽出来 = 其实没核验过,不能当「核验通过」
            text = f'⚠️ 本次未抽到任何可核验声明,本晨报未经有效核验\n\n{brief}'
        else:
            text = compose_report(report, brief)
            risky = (report.get('summary') or {}).get('red', 0)
            if risky:
                text = (f'⚠️ 本晨报含 {risky} 句存疑内容,已逐句标注,请过目后再转发\n\n{text}')
    except (requests.RequestException, ValueError, RuntimeError,
            PermissionError, OSError) as exc:
        # 核验不可用:不静默放行,明确标注「未经核验」。
        # ValueError 必须收 ------ resp.json() 抛的是 json.JSONDecodeError,
        # 它是 ValueError 子类,**不是** requests.RequestException(实测 issubclass 为 False)。
        text = f'⚠️ 核验服务不可用({exc.__class__.__name__}),本晨报未经核验\n\n{brief}'
    notify(text)

ValueError 这一条是实测踩出来的 :网关返回 HTML 502、接口挂了返回非 JSON,resp.json() 抛 JSONDecodeError------它继承自 ValueError,不是 requests.RequestException。只写 except requests.RequestException 的话异常直接穿透 morning_brief(),而幂等锁已经占用了,当天晨报彻底丢失且没有任何提示。

另外两道防线:total == 0 说明一句可核验的声明都没抽出来,那是「没核验过」而不是「核验通过」,不能直接当干净的内容发出去;/detect 返回 200 但 body 是错误结构(额度耗尽、内部错)时同样不能放行,verify_brief 里校验一次结构。

值得强调的是它对「无法核实」的处理:查不到依据的句子老实标注 unverified,而不是硬判成错的------这对晨报场景很重要,宁可让读者知道「这句没查到出处」,也不要把真消息误杀。

实测的真实结果挺有意思:模型生成的「晨报」其实是个占位模板(它拿不到实时数据),还把日期写成了 2026 年 4 月 26 日(当天是 10 月 23 日)。核验把全部 12 条声明标成 🟡 无法核实、幻觉风险 1.00,报告里每句都给了「建议改为:建议核实」。出口安检按预期拒绝给无依据内容背书------这正是它的价值:它不会替你下结论,但会让读者知道哪些话没查到出处。

串起来:完整入口

key 只走环境变量,别落命令行也别进日志:

python 复制代码
def notify(text: str):
    """推送通知:替换成企业微信/钉钉群机器人 webhook 即可"""
    print(f'[NOTIFY] {text}')


def load_env(path: str) -> bool:
    """极简 .env 加载器;已存在的环境变量优先,不覆盖。

    `KEY=` 这种空值行**跳过**:它不该生效,而且 setdefault 会让它抢先占位,
    把后面文件里真正的值整个顶掉。
    """
    if not os.path.isfile(path):
        return False
    with open(path, encoding='utf-8') as f:
        for line in f:
            line = line.strip()
            if not line or line.startswith('#') or '=' not in line:
                continue
            k, v = line.split('=', 1)
            k, v = k.strip(), v.strip().strip('"\'')
            if not k or not v:
                continue
            os.environ.setdefault(k, v)
    return True

先加载 .env 再读配置------顺序反了就拿不到 key,这是最容易犯的错:

python 复制代码
import os
import re

ENV_FILE = os.environ.get('ENV_FILE', '/path/to/.env')
LOCAL_ENV = os.path.join(os.path.dirname(os.path.abspath(__file__)), '.env.local')

_ENV_LOADED = False
for _p in (LOCAL_ENV, ENV_FILE):          # 先加载本地:setdefault 不覆盖,靠前的生效
    _ENV_LOADED = load_env(_p) or _ENV_LOADED


def _find_key(candidates, patterns):
    """先精确名,再正则兜底。返回 (变量名, 值)。

    每次调用都现取 os.environ,不要在 import 期把 key 固化成模块全局变量
    ------那时 .env 还没加载,全局变量永远是空串。
    """
    for name in candidates:
        v = os.environ.get(name)
        if v:
            return name, v
    for k, v in sorted(os.environ.items()):
        if v and any(re.fullmatch(p, k) for p in patterns):
            return k, v
    return '', ''


def llm_key():
    """LLM_BASE 指向哪家,就找那家的变量。改 LLM_BASE 即可换供应商。"""
    if 'deepseek' in LLM_BASE.lower():
        return _find_key(('DEEPSEEK_API_KEY',), ('DEEPSEEK.*KEY',))
    return _find_key(('OPENAI_API_KEY',), ('OPENAI.*KEY',))


def hallucc_key():
    return _find_key(('AIHCC_API_KEY', 'HALLUC_API_KEY'),
                     ('AIHCC.*KEY', 'HALLUC.*KEY'))


AIHCC_BASE = os.environ.get('AIHCC_BASE') or 'https://aihcc.cloud/api'
LLM_BASE = os.environ.get('LLM_BASE') or 'https://api.deepseek.com/v1'
LLM_MODEL = os.environ.get('LLM_MODEL') or 'deepseek-v4-pro'

注意 os.environ.get('LLM_MODEL') or 'deepseek-v4-pro' 用的是 or 而不是 get(k, default)------后者在环境变量存在但值为空串时不会走默认值,你会把一个空的 model 发给 API 换回一个莫名其妙的 400。

agent_run() 用一个 OpenAI 兼容的调用即可,没配 key 时返回离线演示文本,方便你先跑通骨架:

python 复制代码
import json
import requests


def agent_run(prompt: str, output_json: bool = False):
    """调用 OpenAI 兼容接口;未配置 key 时返回离线演示结果。

    返回类型与 output_json 一致:True -> dict,False -> str。
    """
    _, key = llm_key()
    if not key:
        off = ('{"level":"alert","summary":"订单积压 37 单,超过阈值 20"}' if output_json
               else '今日要闻:(离线演示文本,未配置 LLM key)')
        return json.loads(off) if output_json else off

    payload = {'model': LLM_MODEL, 'temperature': 0.2,
               'messages': [{'role': 'user', 'content': prompt}]}
    if output_json:
        payload['response_format'] = {'type': 'json_object'}
    resp = requests.post(f'{LLM_BASE}/chat/completions',
                         headers={'Authorization': f'Bearer {key}'},
                         json=payload, timeout=60)
    resp.raise_for_status()
    content = resp.json()['choices'][0]['message']['content']
    return json.loads(content) if output_json else content

入口:

python 复制代码
if __name__ == '__main__':
    init_db()
    scheduler = build_scheduler()
    print('主动式 Agent 已启动:晨报每天 08:30(Asia/Shanghai),巡检每 15 分钟')
    scheduler.start()      # 阻塞式启动,适合单职责的常驻进程

跑通骨架别等下一个 8:30:把 scheduler.start() 换成一个自检入口,顺序调用 init_db()、build_scheduler()(读触发时刻)、两次 acquire_lock、一轮 guarded_call + review、一次 morning_brief(),再立刻调第二次看它有没有被幂等锁拦下。十几行就能覆盖上面所有分支,也是本文那些「实测」结论的来路。

一个网络相关的实测提醒 :我们这台服务器直连 api.openai.com 是 Network is unreachable(Errno 101),每次重试都得等 60 秒超时,看起来像「程序卡住了」,其实是出口不通。换成 DeepSeek 之后立刻通了。国内服务器的话,L 层选 OpenAI 兼容的国内端点,别默认 api.openai.com。

写在最后:主动式 Agent 的四件套

回看这些代码,其实就是一个公式:主动式 Agent = 调度(什么时候跑)+ 幂等(跑没跑过)+ 审批(能不能做)+ 核验(说得对不对)。

Claude Duet 把这个公式做成了产品,讯飞 X2.6 把「数据边界」做进了端云协同------但对绝大多数业务场景来说,公式本身才是可迁移的资产:你的晨报、你的巡检、你的审批流,接的是你自己的数据和 IM。

这次踩的坑也集中在两个地方:幂等 这一侧是「先占坑再干活,占坑之后崩了怎么办」------锁要能被失败释放,否则一次网络抖动等于任务永久跳过;核验 这一侧是「接口契约别猜」------Base URL、端点、字段名,一个错就静默降级,而且 resp.json() 抛的异常不是 RequestException 的子类,最顺手的那个 except 收不住它。

从「你问我答」到「我主动干活」,中间隔的不是模型能力,是这四件工程套件。装好它们,你的 Agent 才真正够格叫「主动式」。

相关推荐
我叫洋洋1 小时前
用 Qoder(AI 编程助手)把 Cadence 16.6 从零配到能画板
人工智能·单片机·嵌入式硬件·电路·1024程序员节
小小张说故事1 小时前
Python 的函数调用到底贵在哪?拆开一次 call:51 纳秒栈帧与 *args 的 40 纳秒罚单
python
代码山河2 小时前
大模型的幻觉是什么?为什么会产生幻觉
人工智能·ai·大模型
数聚天成DeepSData2 小时前
验收一批标注数据时,一致性到底在比谁和谁
人工智能·深度学习·机器学习·数据集·deepsdata
码农小白AI2 小时前
监督抽查报告要素不全?IACheck AI 报告文档审核 Agent 做报告要素完整性审查
人工智能
幻智星科技2 小时前
多通道数据采集中collect_end信号的妙用
网络协议·架构·udp·硬件架构
程序猿乐锅2 小时前
【从0-1学习Python | 第七篇】Python 面向对象基础
开发语言·笔记·后端·python·学习·1024程序员节
lzqrzpt2 小时前
太阳能控制器选型要点:电路工艺与工程适配技术解析
python·1024程序员节
海宇AI2 小时前
零信任架构实战:基于海宇行驶OCR证识别构建自动化租车车队行驶证电子档案网关
运维·人工智能·架构·自动化·1024程序员节