昨天(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 才真正够格叫「主动式」。