Manus API 可以在
task.create时附上structured_output_schema,任务结束后按你的 JSON Schema 抽取结果。 这篇做schema_lint.py:在调用 API 前按官方规则检查 Schema(这些规则比普通 JSON Schema 严格得多),并在拿到结果后只在success: true时使用数据。
先看清成品
- 我按习惯写的第一版 Schema 被检查出 6 个问题,改成第二版后通过。
- 真实调用发现:带着不合规 Schema 用无效 Key 请求,服务端先返回 401,不会告诉你 Schema 的问题——没有 Key 时只能靠本地检查。
- 7 项测试通过,包括官方文档里的两个示例 Schema(对象规则、递归树)。
合规提醒:只使用 Manus 官方 API v2(
https://api.manus.ai/v2/)和你自己账号创建的 API Key,不共享账号或 Key,不用任何非官方封装或网页自动化。API 任务和网页任务一样消耗积分,额度与价格以 manus.im 官网为准(本文不写价格)。Agent 发邮件、部署、执行命令这类动作必须由人确认。让 Agent 访问的网站也要遵守对方的使用条款。
运行说明:我没有 Manus API Key,所以没有创建过真实任务。真实跑过的是:用无效 Key 调用官方 API(2026-10-21 21:09–21:13,UTC+8,返回内容原样贴在文中),以及全部本地代码。结构化结果(success true/false)来自我写的 mock,字段照官方 v2 文档的示例和字段说明写,已在文中标出。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
schema_lint.py |
Schema 规则检查 + use_result() |
schema_v1.json |
我第一版写的 Schema(不合规) |
schema_v2.json |
修正后的 Schema |
test_schema_lint.py |
7 项测试 |
需要准备哪些环境?
| 项目 | 说明 |
|---|---|
| Manus 账号 | 在 manus.im 注册;API Key 在 设置 → Developers(manus.im/app#settings/developers)创建 |
| Python | 3.9 以上,pip install requests pytest(B、C 篇另需 cryptography) |
| 环境变量 | export MANUS_API_KEY=...,不要写进代码或提交到 Git |
关键事实
| 事实 | 来源 |
|---|---|
| 根节点必须是 object | 官方 Structured Output |
每个 object 都要 additionalProperties: false,且 required 列出全部字段;可选字段用可空类型 ["string","null"] |
同上 |
| 支持 enum、description、anyOf、类型数组、$ref/$defs;不支持 pattern、format、minLength/maxLength、数值范围、minItems/maxItems/uniqueItems、allOf/oneOf/not、if/then/else | 同上 |
| 对象和数组最多嵌套 5 层;不合规 Schema 直接返回 HTTP 400 | 同上 |
| 结果固定有 success、value、error;success 为 false 时 value 是“零值兜底”(空字符串等),error 说明原因 | 同上 |
“5 层”我按“每遇到一个 object 或 array 算一层,根对象算第 1 层”实现,这是我的理解;以服务端返回为准。
文件结构
81-manus-C-src/
├── schema_lint.py
├── schema_v1.json
├── schema_v2.json
└── test_schema_lint.py
第 1 步:检查脚本
"""在调用 task.create 之前,按 Manus 官方 Structured Output 规则检查 JSON Schema;
任务结束后,只在 success=true 时使用 value。"""
import json, sys
UNSUPPORTED = {"pattern", "format", "minLength", "maxLength", "minimum", "maximum",
"exclusiveMinimum", "exclusiveMaximum", "multipleOf", "minItems", "maxItems",
"uniqueItems", "allOf", "oneOf", "not", "if", "then", "else"}
MAX_DEPTH = 5
def lint(schema):
errs = []
if schema.get("type") != "object":
errs.append("$: 根节点必须是 type=object")
defs = schema.get("$defs", {})
def walk(node, path, depth, seen_refs):
if not isinstance(node, dict):
return
if "$ref" in node:
name = node["$ref"].rsplit("/", 1)[-1]
if name in seen_refs: # 递归结构:不再展开
return
return walk(defs.get(name, {}), path + f"->{name}", depth, seen_refs | {name})
for k in node:
if k in UNSUPPORTED:
errs.append(f"{path}: 不支持的关键字 `{k}`")
types = node.get("type")
types = types if isinstance(types, list) else [types]
if "object" in types or "array" in types:
depth += 1
if depth > MAX_DEPTH:
errs.append(f"{path}: 嵌套超过 {MAX_DEPTH} 层")
return
if "object" in types:
props = node.get("properties", {})
if node.get("additionalProperties") is not False:
errs.append(f"{path}: additionalProperties 必须为 false")
missing = sorted(set(props) - set(node.get("required", [])))
if missing:
errs.append(f"{path}: required 必须列出全部字段,缺 {missing}(可选字段改用可空类型)")
for k, v in props.items():
walk(v, f"{path}.{k}", depth, seen_refs)
if "array" in types:
if "items" not in node:
errs.append(f"{path}: array 必须指定 items")
walk(node.get("items"), f"{path}[]", depth, seen_refs)
for sub in node.get("anyOf", []):
walk(sub, path, depth, seen_refs)
walk(schema, "$", 0, frozenset())
return errs
def use_result(result):
"""result 是 structured_output_result 或 webhook 里的 structured_output。"""
if not result.get("success"):
# value 此时是“零值兜底”(空字符串、0、空数组……),不能当真实数据入库
raise ValueError(f"结构化输出失败:{result.get('error')}")
return result["value"]
if __name__ == "__main__":
s = json.load(open(sys.argv[1], encoding="utf-8"))
errs = lint(s)
for e in errs:
print("✗", e)
print("✓ 通过" if not errs else f"共 {len(errs)} 个问题")
sys.exit(1 if errs else 0)
第 2 步:第一版 Schema(不合规)
{
"type": "object",
"properties": {
"apps": {
"type": "array",
"minItems": 3,
"items": {
"type": "object",
"properties": {
"name": {"type": "string", "minLength": 1},
"license": {"type": "string"},
"homepage": {"type": "string", "format": "uri"},
"multi_currency": {"type": "boolean"},
"last_release": {"type": "string", "description": "YYYY-MM-DD"}
},
"required": ["name", "license", "homepage"]
}
}
},
"required": ["apps"]
}
第 3 步:修正后的 Schema
{
"type": "object",
"properties": {
"apps": {
"type": "array",
"description": "恰好 3 款;查不到的字段填 null",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"license": {"type": ["string", "null"]},
"homepage": {"type": "string", "description": "官网完整 URL"},
"multi_currency": {"type": ["boolean", "null"]},
"last_release": {"type": ["string", "null"], "description": "YYYY-MM-DD,未找到填 null"},
"sources": {"type": "array", "items": {"type": "string"}}
},
"required": ["name", "license", "homepage", "multi_currency", "last_release", "sources"],
"additionalProperties": false
}
}
},
"required": ["apps"],
"additionalProperties": false
}
第 4 步:测试
import json, pytest
from schema_lint import lint, use_result
def obj(props):
return {"type": "object", "properties": props, "required": list(props), "additionalProperties": False}
def test_v1_fails_v2_passes():
assert len(lint(json.load(open("schema_v1.json", encoding="utf-8")))) == 6
assert lint(json.load(open("schema_v2.json", encoding="utf-8"))) == []
def test_official_example_passes(): # 官方文档 Object Rules 示例
s = obj({"name": {"type": "string"}, "age": {"type": "integer"}, "nickname": {"type": ["string", "null"]}})
assert lint(s) == []
def test_official_recursive_tree_passes():
s = {"type": "object", "properties": {"root": {"$ref": "#/$defs/TreeNode"}}, "required": ["root"],
"additionalProperties": False,
"$defs": {"TreeNode": {"type": "object", "properties": {"label": {"type": "string"},
"children": {"type": "array", "items": {"$ref": "#/$defs/TreeNode"}}},
"required": ["label", "children"], "additionalProperties": False}}}
assert lint(s) == []
def test_depth_5_ok_6_rejected():
five = obj({"a": obj({"b": obj({"c": obj({"d": obj({"e": {"type": "string"}})})})})})
assert lint(five) == []
six = obj({"a": obj({"b": obj({"c": obj({"d": obj({"e": obj({"f": {"type": "string"}})})})})})})
assert any("嵌套超过" in e for e in lint(six))
def test_root_must_be_object_and_array_needs_items():
assert lint({"type": "array", "items": {"type": "string"}})[0].startswith("$: 根节点")
assert any("items" in e for e in lint(obj({"x": {"type": "array"}})))
def test_oneof_rejected_anyof_ok():
assert lint(obj({"x": {"anyOf": [{"type": "string"}, {"type": "null"}]}})) == []
assert lint(obj({"x": {"oneOf": [{"type": "string"}]}}))
def test_use_result():
assert use_result({"success": True, "value": {"a": 1}, "error": None}) == {"a": 1}
with pytest.raises(ValueError, match="Failed to extract"):
use_result({"success": False, "value": {"country": "", "capital": ""}, "error": "Failed to extract structured output"})
第 5 步:创建任务时带上 Schema
import json, os, requests
schema = json.load(open("schema_v2.json", encoding="utf-8"))
r = requests.post("https://api.manus.ai/v2/task.create",
headers={"x-manus-api-key": os.environ["MANUS_API_KEY"].strip(), "User-Agent": "binbho-tutorial/1.0"},
json={"message": {"content": open("../81-manus-A-src/prompt.txt", encoding="utf-8").read()},
"structured_output_schema": schema}, timeout=60)
print(r.status_code, r.json())
# 任务结束后,在 listMessages 的 structured_output_result 或 webhook 的
# task_detail.structured_output 里拿到结果,交给 use_result()
第 6 步:运行
$ python schema_lint.py schema_v1.json
✗ $: additionalProperties 必须为 false
✗ $.apps: 不支持的关键字 `minItems`
✗ $.apps[]: additionalProperties 必须为 false
✗ $.apps[]: required 必须列出全部字段,缺 ['last_release', 'multi_currency'](可选字段改用可空类型)
✗ $.apps[].name: 不支持的关键字 `minLength`
✗ $.apps[].homepage: 不支持的关键字 `format`
共 6 个问题
$ python schema_lint.py schema_v2.json
✓ 通过
# 真实请求:不合规 Schema + 无效 Key(2026-10-21 21:13 UTC+8)
$ curl -s -X POST https://api.manus.ai/v2/task.create -H 'x-manus-api-key: invalid-demo-key' -H 'Content-Type: application/json' --data-binary @body_with_schema_v1.json
{"error":{"code":"unauthenticated","message":"invalid api key"},"ok":false,"request_id":"ff28b0e5-0c69-4908-a49c-233a6f594fff"} ← HTTP 401
$ python -m pytest -q
7 passed
怎么检查结果?
- 任何
success: false的结果都不入库:use_result()会抛出异常。零值兜底的空字符串看起来像“合法数据”,混进表格最难发现。 success: true也只说明结构符合,内容仍要核对来源(sources字段就是为此加的)。- 原来用 minItems、format 表达的约束,改写进 description,并在拿到结果后用代码检查(例如条目数是否为 3)。
调试示例
1. 习惯写法几乎全不合规。 第一版 Schema 是按普通 JSON Schema 习惯写的:用 minItems: 3 要求 3 条、format: uri 约束网址、minLength 防止空名字、只把必填字段写进 required。检查结果 6 个问题(见运行输出)。修改:删掉所有不支持的关键字,把约束写进 description;所有对象加 additionalProperties: false;“查不到”的字段改成可空类型并放进 required。
2. 服务端先验认证,不会提示 Schema 问题。 我想看看服务端对 v1 Schema 的 400 报错长什么样,带着无效 Key 发了真实请求,得到的是 401 invalid api key。也就是说认证在前,Schema 校验在后:没有可用 Key 时,只能靠本地检查。有 Key 时 400 会扣请求次数(频率限制按分钟计),本地先检查也能少浪费几次。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 400 Schema 被拒 | 用了不支持的关键字或缺 additionalProperties | 先跑 schema_lint.py |
| success:false,value 全是空值 | 抽取失败(Failed to extract structured output)或结果不符合 Schema | 不要用 value;简化 Schema 后重跑,或人工读回答 |
| 想要可选字段 | 规则要求 required 列出全部 | 用 ["类型","null"] |
| 递归结构 | 需要树形数据 | 用 $defs + $ref(官方支持) |
模板规则
- 调用前本地检查 Schema。
- 约束写进 description,结果出来再用代码检查。
- success:false 一律不用 value。
- 给每条数据留 sources 字段。
项目资源
| 用途 | 地址 |
|---|---|
| Manus Structured Output(官方) | https://open.manus.ai/docs/v2/structured-output |
| Manus API 文档:task.create(官方) | https://open.manus.ai/docs/v2/task.create |
| Manus 频率限制(官方) | https://open.manus.ai/docs/v2/rate-limits |
| JSON Schema 官网 | https://json-schema.org/ |
结构化输出让结果能直接进表格,但“能解析”不等于“是对的”。