Guide · 使用教程
Parallel Python 教程:结构化网页研究、任务恢复与引用核验
使用标准库与 SQLite,演示研究提交台账、预算拦截、读取恢复、字段及列表元素证据检查和人工核验。
给研究 API 一个问题,再把返回 JSON 写进数据库,是原型最容易完成的一步。正式使用还要解决三个问题:同一个任务有没有重复提交、每个字段有没有可审核的证据、引用能不能支持当前实体与日期下的结论。本教程用 Parallel Task 构建一个可恢复的公开网页研究示例。
示例使用 Python 3.11+ 标准库,无需第三方 SDK。接口与价格核对于 2026-10-16;验证使用本地合成响应与模拟 HTTP 连接,未调用真实付费研究 API。下面的 create 命令会产生真实研究费用,读者应在自己批准的小样本预算内运行。
1. 固定研究目标与结果契约
示例只查一个明确公司的官网:最多五条公开产品功能、价格页和无法核实的问题。输出字段是 company_name、pricing_page、features、unknowns。没有找到价格页时允许 null;网页无法读取不等于没有该功能。
Parallel 创建接口为 POST /v1/tasks/runs,JSON schema 放在 task_spec.output_schema 的 json_schema 中。返回值从 output.content 读取,Basis 从 output.basis 读取。顶层列表的元素证据使用 features.0 一类路径,不能依赖数组顺序。官方 Task 入门、Research Basis
本例要求只查看目标官网,因此使用 source_policy.include_domains。如果研究应包含第三方背景资料,应改为明确的来源策略并重做样本验收;不要为了保留白名单而强行拼答案。当前 after_date 的支持项是 Search,不把它当成 Task 的可用参数。Source Policy 文档
2. 保存提交意图,再创建一次任务
保存下列代码为 research.py 的前半部分。SQLite 用 job_id 唯一约束先提交本地记录,随后才 POST。相同 job_id 再次创建会在联网前失败;进程重启仍保留这一记录。示例只允许 core / pro / ultra,按核对日公开单价检查本地单任务预算,默认 $0.05,因此默认只放行 core。
这不是账户级限额,也不保证跨网络的恰好一次:若服务端收到请求、客户端没收到 ID,本地只能标记 unknown 并停下。不要换一个 job_id 绕过去。生产批量流程还需要事务化的总预算、并发额度和队列。
"""Python 3.11+; no third party packages. Live create is an explicit paid action."""
import argparse
import http.client
import json
import os
import re
import sqlite3
from datetime import datetime, timezone
from decimal import Decimal
from urllib.parse import urlsplit
PRICES = {"core": Decimal("0.025"), "pro": Decimal("0.10"), "ultra": Decimal("0.30")}
SCHEMA = {
"type": "object", "additionalProperties": False,
"required": ["company_name", "pricing_page", "features", "unknowns"],
"properties": {
"company_name": {"type": "string"},
"pricing_page": {"type": ["string", "null"]},
"features": {"type": "array", "maxItems": 5, "items": {"type": "string"}},
"unknowns": {"type": "array", "items": {"type": "string"}},
},
}
def connect(path):
db = sqlite3.connect(path)
db.row_factory = sqlite3.Row
db.execute("""CREATE TABLE IF NOT EXISTS jobs (
job_id TEXT PRIMARY KEY, state TEXT NOT NULL, request_json TEXT NOT NULL,
run_id TEXT, create_reply TEXT, result_json TEXT)""")
return db
def request_json(method, path, body=None):
# Fixed provider host; citation URLs are never requested here.
connection = http.client.HTTPSConnection("api.parallel.ai", timeout=40)
headers = {"x-api-key": os.environ["PARALLEL_API_KEY"], "Content-Type": "application/json"}
encoded = None if body is None else json.dumps(body).encode("utf-8")
try:
connection.request(method, path, body=encoded, headers=headers)
response = connection.getresponse()
if response.status not in (200, 202):
raise RuntimeError(f"provider HTTP {response.status}; do not resubmit automatically")
raw = response.read(5_000_001)
if len(raw) > 5_000_000:
raise ValueError("response exceeds local size limit")
value = json.loads(raw)
if not isinstance(value, dict):
raise ValueError("expected response object")
return value
finally:
connection.close()
def payload(company, domain, as_of, processor="core"):
return {
"input": {"company": company, "official_domain": domain, "as_of": as_of,
"objective": "Only research this company on its official site. Return up to five publicly documented product features and its pricing page URL. Use null or unknowns when unverifiable. Do not interpret inaccessible pages as proof of absence. Do not invent publication dates."},
"processor": processor,
"source_policy": {"include_domains": [domain]},
"task_spec": {"output_schema": {"type": "json", "json_schema": SCHEMA}},
}
def submit(db, job_id, request, limit=Decimal("0.05"), api=request_json):
cost = PRICES.get(request.get("processor"))
if cost is None or cost > limit:
raise ValueError("processor exceeds local per-run budget or is not approved")
# Commit intent before POST. Duplicate job_id fails before any network call.
with db:
db.execute("INSERT INTO jobs(job_id,state,request_json) VALUES(?,?,?)",
(job_id, "creating", json.dumps(request, ensure_ascii=False)))
try:
reply = api("POST", "/v1/tasks/runs", request)
with db:
db.execute("UPDATE jobs SET create_reply=? WHERE job_id=?", (json.dumps(reply), job_id))
run_id = reply.get("run_id")
if not isinstance(run_id, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,128}", run_id):
raise ValueError("missing or malformed run_id; reconcile create_reply")
with db:
db.execute("UPDATE jobs SET state='submitted',run_id=? WHERE job_id=?", (run_id, job_id))
return run_id
except Exception:
with db:
db.execute("UPDATE jobs SET state='unknown' WHERE job_id=?", (job_id,))
raise
def collect(db, job_id, api=request_json):
row = db.execute("SELECT * FROM jobs WHERE job_id=?", (job_id,)).fetchone()
if row is None or not row["run_id"]:
raise ValueError("no known run_id; reconcile without creating a replacement")
result = api("GET", f"/v1/tasks/runs/{row['run_id']}/result?timeout=30")
# Retain raw result even if subsequent validation fails.
with db:
db.execute("UPDATE jobs SET result_json=? WHERE job_id=?", (json.dumps(result), job_id))
run = result.get("run") or {}
if run.get("run_id") != row["run_id"] or run.get("status") != "completed":
raise ValueError("result identity or completion state mismatch")
with db:
db.execute("UPDATE jobs SET state='completed' WHERE job_id=?", (job_id,))
return result
请求仅发送到固定 Parallel 主机,没有自动重试或跳转。HTTP 408 表示取结果的等待超时,任务可能仍在运行;再次执行 collect 即可读取同一 ID。HTTP 404、失败状态或创建阶段异常应先核对记录与控制台,不能一律重新创建。创建接口、读取结果接口
数据库先保存原始创建响应与结果,再做校验。创建回复已有 ID、但进程在更新 state 前退出时,可从 create_reply 恢复原 ID;应先核对账户、任务输入和 provider 记录,再由维护人员修复台账。示例没有自动执行这一恢复写入。
3. 将缺证据和格式变化挡在发布前
继续把下列代码追加到同一个 research.py。此检查仅覆盖结果形状、实体名称、逐字段引用、来源域、摘录和 confidence。它不联网读取引用,也不判定事实真假。即使全部检查通过,状态也只能是 pending_manual_review。
def source_url(value, domain):
if not isinstance(value, str):
return False
try:
u = urlsplit(value)
host = (u.hostname or "").lower()
return (u.scheme == "https" and not u.username and not u.password
and u.port in (None, 443) and (host == domain or host.endswith("." + domain)))
except ValueError:
return False
def evidence_gate(result, expected_company, domain):
"""Preflight only. Never declares a factual claim verified or publishable."""
issues = []
output = result.get("output") or {}
content = output.get("content")
if output.get("type") != "json" or not isinstance(content, dict):
return {"state": "blocked", "issues": ["expected JSON output"]}
fields = set(SCHEMA["required"])
if set(content) != fields:
issues.append("schema fields mismatch")
if content.get("company_name") != expected_company:
issues.append("entity name mismatch")
features, unknowns = content.get("features"), content.get("unknowns")
if not isinstance(features, list) or len(features) > 5 or any(not isinstance(v, str) or not v.strip() for v in features):
issues.append("invalid features")
features = []
if not isinstance(unknowns, list) or any(not isinstance(v, str) or not v.strip() for v in unknowns):
issues.append("invalid unknowns")
price_url = content.get("pricing_page")
if price_url is not None and not source_url(price_url, domain):
issues.append("pricing URL outside expected official domain")
basis = output.get("basis")
mapped = {}
if not isinstance(basis, list):
issues.append("missing basis")
basis = []
for entry in basis:
if not isinstance(entry, dict) or not isinstance(entry.get("field"), str):
issues.append("malformed basis entry")
continue
name = entry["field"]
if name in mapped:
issues.append("duplicate basis field: " + name)
mapped[name] = entry
required = ["company_name"] + (["pricing_page"] if price_url is not None else [])
required += [f"features.{i}" for i in range(len(features))]
for field in required:
entry = mapped.get(field, {})
citations = entry.get("citations")
if not isinstance(citations, list) or not citations:
issues.append("missing citations: " + field)
continue
for cite in citations:
excerpts = cite.get("excerpts") if isinstance(cite, dict) else None
if (not isinstance(cite, dict) or not source_url(cite.get("url"), domain)
or not isinstance(excerpts, list) or not excerpts
or any(not isinstance(t, str) or not t.strip() for t in excerpts)):
issues.append("unusable evidence: " + field)
if entry.get("confidence") != "high":
issues.append("confidence needs review: " + field)
return {"state": "blocked" if issues else "pending_manual_review", "issues": issues,
"observed_at": datetime.now(timezone.utc).isoformat(),
"note": "No source was fetched; entity, dates, claim support and conflicts still require human review."}
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("action", choices=["create", "collect"])
parser.add_argument("job_id")
parser.add_argument("--db", default="research.sqlite3")
args = parser.parse_args()
with connect(args.db) as db:
if args.action == "create":
print(submit(db, args.job_id, payload("Parallel Web Systems", "parallel.ai", "2026-10-16")))
else:
result = collect(db, args.job_id)
print(json.dumps(evidence_gate(result, "Parallel Web Systems", "parallel.ai"), ensure_ascii=False, indent=2))
来源域检查是业务来源筛选,不是通用安全抓取器。当前代码不会请求 citation URL;如果另做抓取服务,需要独立处理 DNS、地址段、重定向、大小及超时等边界。不要把示例里的域匹配函数用于任意 URL 代理。
high 只是 provider 的置信标记。它可以用于决定审核优先级,不能证明功能、价格、日期或公司身份正确。缺少摘录、缺元素引用、出现重复 field 或 schema 变化时,本例保留原始响应并阻塞;可以人工查原文补证据,不能静默忽略异常。
4. 在批准预算内创建和恢复
设置自己的 Parallel API Key 环境变量,避免写进源码、日志或数据库。示例内公司、官网和截止日期应由可信配置指定,首次运行前按实际目标修改。日期描述研究范围,不等于证明来源当天更新。
python3 research.py create parallel-public-profile-001
python3 research.py collect parallel-public-profile-001
单次请求的 HTTP 超时设为 40 秒,结果 API 的等待参数设为 30 秒;两者都不是整个研究任务的最长运行时间。本教程不使用循环轮询,避免读者复制后无上限等待。生产 worker 应设置退避、总时限和人工告警,同时继续保留原 run ID。
2026-10-16 的 core / pro / ultra 分别为 $0.025 / $0.10 / $0.30 每成功 run,示例价格常量需随费率更新。1,000 次成功 core 为 $25,另加证据归档、应用资源和人工审核。远端成功而业务审核未通过仍可能计费,不能靠审核拒绝规避账单。官方价格
5. 人工核验要记录什么
对每条功能,从 result_json 中取具体值、对应 field 的引用和摘录,然后打开官方原文核对。记录以下信息,作为自己的审核表;它不是 Parallel 原生返回结构。
{
"field": "features.0",
"claim": "待审核的功能描述",
"source_url": "https://目标官网/具体文档路径",
"source_published_at": null,
"observed_at": "由应用记录本次查看时间",
"supports_claim": null,
"entity_matches": null,
"date_scope_matches": null,
"reviewer": null,
"decision": "pending"
}
重点检查四件事:原文是否属于目标实体,是否真的支持这条具体描述,适用版本和时间是否一致,是否还有冲突材料。复制同一篇稿件的五个网站不算五份独立证据。来源发布日期无法确认时保持 null,并降低结论范围。
遇到官网无法打开,保留 unknowns;遇到只写“计划推出”,不能改写为“已上线”。若 pricing_page 指向联系销售页面,说明存在公开定价入口不等于已有数字价格。审核表的 approved 只是业务审核结果,不会自动触发 CMS 写入;应由应用自己的发布流程处理。
6. 本地验证及尚未覆盖的边界
随本地内容包提供的 test_research.py 覆盖十二组测试,包括固定主机与请求结构、重启后重复提交、创建超时、缺失 ID、预算拦截、读取超时后恢复、结果 ID 不符、Basis 顺序变化、列表元素缺引用、伪装域与凭据 URL、缺摘录/低 confidence/重复 field/实体不符,以及空值与 schema 变化。
python3 -m unittest discover -s aimazing/assets/parallel -p 'test_research.py' -v
这些测试验证本地代码分支与文档契约,合成摘录并不证明真实证据有效。尚需在自己的测试账户验收真实响应、账户配额、任务失败、费用明细和网页可访问性。先用一两个公开样本核对,再扩大任务数;先减少重复提交和无效审核,比直接升级到最高 processor 更容易形成可靠流程。
工具选择参见 Parallel 工具页、深度研究 API 推荐、Parallel vs Exa 与 Parallel vs Tavily。
常见问题
- 教程必须安装 Parallel SDK 吗?
- 本例使用 Python 3.11+ 标准库,通过官方 REST endpoint 请求;未固定或依赖 SDK 的自动重试行为。
- 重复运行 create 会重复计费吗?
- 相同数据库中的同一 job_id 会在联网前被唯一约束拒绝。它不保证跨外部网络恰好一次,不能换 ID 绕过待核对任务。
- 创建超时没拿到 run_id 怎么处理?
- 保留 unknown、原始创建回复和任务意图,先核对 provider 记录。若已拿到 ID 则恢复读取,不要直接重新 POST。
- 父字段有引用,列表元素也算有证据吗?
- 不能自动这样认定。示例要求每个 features.i 路径有可用证据,Basis 按 field 映射,不依赖数组顺序。
- 预检查通过是否代表事实已验证?
- 不是。状态仍为 pending_manual_review,代码没有请求引用原文;实体、日期、原文支持与冲突需要人工审核。
- 十二组测试调用真实付费 API 了吗?
- 没有。测试使用合成响应与模拟 HTTP 连接,验证本地分支和请求契约;真实配额、费用和研究质量需用自己的账户验收。