通过 API 调用工作流时,短文档可以等待一份完整 JSON;文件多了、模型调用慢了,使用者就容易面对长时间没有反馈的等待。
SSE 能在执行过程中持续传回事件。本篇承接第十篇的文件上传接口,增加流式事件解析、运行 ID 保存和断线后的只读查询,让程序既能观察执行过程,也能区分"收到消息""连接断开"和"任务完成"。
正文提供独立 Python 客户端、Mac 与 Windows 运行步骤、演示文件及本地构造的协议示例。客户端同时支持 run 和 query,不依赖上一篇的 Python 文件。
1. 本次流程与适用环境
text
run:本地 TXT → 上传文件 → POST streaming → 逐帧读取事件
├─尽早保存 task_id / workflow_run_id
├─记录节点事件
└─完成事件 → 校验状态与 outputs → 保存报告
断线或未完成:保留已有日志与运行 ID
query:人工使用已知 workflow_run_id → GET 运行详情 → 核实状态与输出
| 项目 | 本文约定 |
|---|---|
| Dify | 社区版 1.17.1,自部署 |
| 客户端 | Python 3.11 或以上版本,requests |
| 系统 | macOS 终端 / Windows PowerShell |
| 应用 | 已发布 Workflow |
| 文件输入 | inputs.documents,File List,本地 TXT,1~3 份 |
| 最终输出 | report:String;data:Object |
| 业务记录 | 每份文件有序号、文件名及 success / skipped / failed 状态 |
资料核对日期:2026-10-22。工作流业务配置沿用第九篇的文件清单与失败保位设计;接口输入与输出沿用第十篇。本篇关注外部程序如何读取流式结果,不重新搭建业务节点。
SSE 提供持续反馈,但仍受网络、代理、客户端和服务端配置影响,不保证连接永远不断,也不使一次新的 POST 自动变成上次运行的续接。官方响应模式说明
2. 请求体改为 streaming,读取方式也要改变
文件上传仍是 POST /v1/files/upload,取得根级 id 后组装自定义文件列表。关键运行入参是:
json
{
"inputs": {
"documents": [
{
"type": "document",
"transfer_method": "local_file",
"upload_file_id": "00000000-0000-4000-8000-000000000001"
}
]
},
"response_mode": "streaming",
"user": "csdn-demo-user-001"
}
这里的 UUID 是占位示例,程序会填入实际上传返回的 ID。上传与运行使用同一应用 API Key、同一个稳定 user,输入名必须与 Workflow 的 documents 一致。
客户端发送请求时设置 stream=True,先检查 HTTP 状态和响应 Content-Type 是否为 text/event-stream,再按事件读取;不能对整条 SSE 响应调用 response.json()。官方流式调用说明、Requests 流式读取
注意两个参数的职责:response_mode 告诉 Dify 返回 SSE,requests 的 stream=True 告诉客户端逐步读取响应。只改其中一个,不等于完成了整条流式调用逻辑。
3. 看懂 SSE 的行、帧和 JSON 事件
固定版本常见线格式:
text
event: ping
data: {"event":"workflow_started","task_id":"...","workflow_run_id":"...","data":{...}}
data: {"event":"node_finished","task_id":"...","workflow_run_id":"...","data":{...}}
上面的省略号只用于解释结构,不是可以解析或提交的完整 JSON。
三个要点:
- SSE 以空行分隔事件帧,不以任意一次网络读取作为事件边界。
- 普通业务帧的
data:内容是 JSON,事件种类看 JSON 中的event字段。 event: ping是没有 JSON 数据的心跳帧,不要拿它做 JSON 解析,也不要把它当作工作流开始或完成。
Dify 1.17.1 初始订阅会先发送心跳,默认心跳间隔约 10 秒;仍应以收到 workflow_started 等实际事件判断执行进展。固定版本心跳与流读取、消息生成器默认配置
通用 SSE 还允许同一帧有多行 data:,应以换行拼接后再解析;文本使用 UTF-8。读取块可能切在 CRLF 换行或中文字符中间,解析器需要处理这些边界。本例实现逐行与分帧逻辑,不把它称为完整的浏览器 EventSource 实现。SSE 标准
4. 哪些事件要处理,哪些信息不能误读
| 事件 | 客户端处理 |
|---|---|
| workflow_started | 尽早保存 task_id、workflow_run_id |
| node_started | 记录节点开始事件,展示安全的 node_id |
| node_finished | 记录节点状态,继续等待工作流层面的结果 |
| text_chunk | 可记入日志;本例不把它拼成最终报告 |
| workflow_finished | 保存完成事件,再校验运行状态和 outputs |
| error | 保存已有信息,结束本次读取并报错 |
| workflow_paused | 记录暂停,明确结果尚未完成 |
4.1 两个 ID 的用途
| 标识 | 用途 |
|---|---|
| task_id | 执行中的任务控制,例如停止任务 |
| workflow_run_id | 识别某一次运行,用于查询运行详情 |
| workflow_id | 工作流版本标识,不是本次运行 ID |
普通工作流与节点事件带有顶层 task_id、workflow_run_id。节点事件里的 data.id 是节点执行 ID,不能拿它查询整条工作流。
error 是一个例外:固定版本可以没有 task_id,且开始之前的错误可能让 workflow_run_id 为 null。因此,客户端只在收到有效 ID 时保存;没有 ID 的 error 仍必须进入错误处理,不能因为"双 ID 不齐"而忽略错误。固定版本事件模型、工作流事件转换器
4.2 节点失败不等于立即结束整个客户端
第九篇的 Continue on Error 和 LLM 异常分支允许记录单项失败后继续处理其他文件。收到某个 node_finished 的失败状态时,不能立即丢掉后续事件;最后可能仍会得到 partial-succeeded 和完整的逐份报告。
同样,text_chunk 不保证出现。没有文字片段不代表没有执行,最终报告以已校验的完成事件或详情查询的 outputs 为准。固定版本节点与输出处理
4.3 不把节点数换算成通用百分比
分支、迭代和重试会改变节点的实际执行次数。某个 node_id 可以多次出现,事件中的 index 也不是完成百分比。本例只展示收到的事件和节点标识,不生成一个缺少可靠分母的进度百分比。
5. 准备文件与依赖
5.1 api-notes-a.txt
text
星舟团队账号资料安排(API 演示资料 A)
记录日期:2026-10-22
以下人员和安排均为虚构。
王宁负责在 2026-10-27 前整理账号申请说明,补充服务台入口和必填信息。
说明必须保留"1 个工作日内反馈进度",不能改成承诺在 1 个工作日内完成开通。
团队还决定补充常见问题清单,但没有指定负责人和截止时间。
5.2 api-notes-b.txt
text
星舟团队反馈核对安排(API 演示资料 B)
记录日期:2026-10-22
以下人员和安排均为虚构。
陈澄负责在 2026-10-28 前核对 8 条试用意见,保留原文并标注重复记录。
8 条意见是待核对的记录数量,不代表已经确认 8 个独立缺陷。
有人提出自动分类的建议,但本次没有形成实施决定;正式上线日期仍未确定。
分别保存为 UTF-8 TXT。第一次只用 A 文件,确认流程后再同时上传两份。
依赖文件 dify-api-requirements.txt:
text
requests>=2.32,<3
macOS,在资料所在目录执行:
bash
python3 -m venv .venv
.venv/bin/python -m pip install -r dify-api-requirements.txt
Windows PowerShell:
powershell
py -3 -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r dify-api-requirements.txt
如果 Windows 没有 py 命令,用本机已安装的 Python 3 启动命令创建环境。后面直接调用虚拟环境里的解释器,不需要激活环境或修改执行策略。
6. 完整独立客户端
保存为 dify-workflow-sse-client.py:
python
"""Dify 1.17.1: run 1-3 TXT files over SSE, or query a known run ID; needs requests."""
import argparse
import codecs
import getpass
import json
import os
from pathlib import Path
import re
import sys
try:
import requests
except ImportError:
print("stage=dependency error_class=ModuleNotFoundError", file=sys.stderr)
raise SystemExit(1)
TIMEOUT = (10, 70)
class IncompleteRun(Exception):
pass
def safe_token(value):
return isinstance(value, str) and re.fullmatch(r"[A-Za-z0-9_-]{1,128}", value) is not None
def save_json(path, value):
path.write_text(json.dumps(value, ensure_ascii=False, indent=2, allow_nan=False) + "\n", encoding="utf-8")
def check_http(response):
if not 200 <= response.status_code < 300:
raise requests.HTTPError()
def validate_data(data, paths=None):
if not isinstance(data, dict):
raise ValueError()
names = ("record_count", "success_count", "skipped_count", "failed_count")
if any(type(data.get(name)) is not int or data[name] < 0 for name in names):
raise ValueError()
records = data.get("records")
if not isinstance(records, list) or not 1 <= len(records) <= 3:
raise ValueError()
if data["record_count"] != len(records) or (paths is not None and len(records) != len(paths)):
raise ValueError()
if sum(data[name] for name in names[1:]) != data["record_count"]:
raise ValueError()
counts = {"success": 0, "skipped": 0, "failed": 0}
for index, row in enumerate(records, start=1):
if not isinstance(row, dict) or row.get("status") not in counts:
raise ValueError()
if type(row.get("file_index")) is not int or row["file_index"] != index:
raise ValueError()
name = row.get("filename")
if not isinstance(name, str) or not name.strip() or re.search(r"[/\\\x00-\x1f]", name):
raise ValueError()
if paths is not None and name != paths[index - 1].name:
raise ValueError()
counts[row["status"]] += 1
if any(data[status + "_count"] != count for status, count in counts.items()):
raise ValueError()
def utf8_lines(response):
decoder = codecs.getincrementaldecoder("utf-8")(errors="strict")
line, after_cr = [], False
for chunk in response.iter_content(chunk_size=1):
for char in decoder.decode(chunk):
if after_cr and char == "\n":
after_cr = False
continue
after_cr = char == "\r"
if char in "\r\n":
yield "".join(line)
line.clear()
else:
line.append(char)
decoder.decode(b"", final=True)
# Do not turn an unterminated last line into an extra newline.
def sse_objects(response):
data_lines = []
for line in utf8_lines(response):
if not line:
if data_lines:
value = json.loads("\n".join(data_lines))
if not isinstance(value, dict):
raise ValueError()
yield value
data_lines = []
elif not line.startswith(":"):
field, _, value = line.partition(":")
if field == "data":
data_lines.append(value[1:] if value.startswith(" ") else value)
# An unterminated frame at EOF is not a completed SSE event.
def remember_ids(payload, identifiers, destination):
sources = [payload]
if payload.get("event") == "workflow_paused" and isinstance(payload.get("data"), dict):
sources.append({"workflow_run_id": payload["data"].get("workflow_run_id")})
updated = dict(identifiers)
for source in sources:
for name in ("workflow_run_id", "task_id"):
value = source.get(name)
if value is None:
continue
if not safe_token(value) or (name in updated and updated[name] != value):
raise ValueError()
updated[name] = value
if updated != identifiers:
save_json(destination / "run-ids.json", updated)
identifiers.update(updated)
def read_stream(response, destination):
check_http(response)
content_type = response.headers.get("Content-Type", "").split(";", 1)[0].strip().lower()
if content_type != "text/event-stream":
raise ValueError()
response.encoding = "utf-8"
identifiers = {}
with (destination / "events.jsonl").open("w", encoding="utf-8") as log:
for payload in sse_objects(response):
log.write(json.dumps(payload, ensure_ascii=False, allow_nan=False) + "\n")
log.flush()
remember_ids(payload, identifiers, destination)
event = payload.get("event")
if not safe_token(event):
raise ValueError()
data = payload.get("data")
node_id = data.get("node_id") if isinstance(data, dict) else None
print("event=" + event + (" node_id=" + node_id if safe_token(node_id) else ""))
if event == "error":
raise RuntimeError()
if event == "workflow_paused":
print("workflow_status=paused result=incomplete")
raise IncompleteRun()
if event == "workflow_finished":
save_json(destination / "workflow-finished.json", payload)
return data
raise IncompleteRun()
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="mode", required=True)
run = commands.add_parser("run", help="Upload TXT files and start one streaming run")
run.add_argument("files", nargs="+")
run.add_argument("--output-dir", default="sse-result")
query = commands.add_parser("query", help="Read details of a known run; does not resume SSE")
query.add_argument("--run-id", required=True)
query.add_argument("--output-dir", default="query-result")
args = parser.parse_args(argv)
stage = "preflight"
try:
paths = [Path(value) for value in args.files] if args.mode == "run" else None
if paths is not None:
if not 1 <= len(paths) <= 3 or any(not p.is_file() or p.suffix.lower() != ".txt" for p in paths):
raise ValueError()
elif not safe_token(args.run_id):
raise ValueError()
stage = "output_dir"
destination = Path(args.output_dir)
destination.mkdir(parents=True, exist_ok=False)
stage = "configuration"
base = os.getenv("DIFY_BASE_URL", "http://localhost/v1").rstrip("/")
user = os.getenv("DIFY_USER", "csdn-demo-user-001").strip()
key = os.getenv("DIFY_API_KEY", "").strip() or getpass.getpass("Dify API Key: ").strip()
if not key or not base.startswith(("http://", "https://")) or (paths is not None and not user):
raise ValueError()
with requests.Session() as session:
session.headers.update({"Authorization": "Bearer " + key})
if args.mode == "query":
stage = "query"
with session.get(base + "/workflows/run/" + args.run_id,
timeout=TIMEOUT, allow_redirects=False) as response:
result = response.json()
save_json(destination / "run-detail.json", result)
check_http(response)
if isinstance(result, dict) and result.get("id") not in (None, args.run_id):
raise ValueError()
else:
documents = []
for index, path in enumerate(paths, start=1):
stage = "upload_" + str(index)
with path.open("rb") as handle:
response = session.post(base + "/files/upload", data={"user": user},
files={"file": (path.name, handle, "text/plain")},
timeout=TIMEOUT, allow_redirects=False)
check_http(response)
uploaded = response.json()
if not isinstance(uploaded, dict) or not isinstance(uploaded.get("id"), str) or not uploaded["id"].strip():
raise ValueError()
documents.append({"type": "document", "transfer_method": "local_file", "upload_file_id": uploaded["id"]})
stage = "stream_read"
with session.post(base + "/workflows/run", stream=True,
json={"inputs": {"documents": documents}, "response_mode": "streaming", "user": user},
timeout=TIMEOUT, allow_redirects=False) as response:
result = read_stream(response, destination)
stage = "workflow_status"
if not isinstance(result, dict):
raise ValueError()
status = result.get("status")
if status in {"running", "paused"}:
print("workflow_status=" + status + " result=incomplete")
raise IncompleteRun()
if status not in {"succeeded", "partial-succeeded"}:
raise RuntimeError()
stage = "validate_outputs"
outputs = result.get("outputs")
if not isinstance(outputs, dict) or not isinstance(outputs.get("report"), str):
raise ValueError()
data = outputs.get("data")
validate_data(data, paths)
stage = "save_outputs"
save_json(destination / "data.json", data)
(destination / "report.md").write_text(outputs["report"], encoding="utf-8")
print("workflow_status=" + status)
print(f"record_count={data['record_count']} success={data['success_count']} "
f"skipped={data['skipped_count']} failed={data['failed_count']}")
return 0
except Exception as error:
print(f"stage={stage} error_class={type(error).__name__}", file=sys.stderr)
return 2 if isinstance(error, IncompleteRun) else 1
if __name__ == "__main__":
raise SystemExit(main())
这份代码有两个入口:
run:校验全部文件、上传、启动一次流式执行,并在完成后保存报告。query:对已知运行 ID 发一次 GET,只查询原运行,不重新上传或发起 POST。
程序要求结果目录此前不存在。API Key 从 DIFY_API_KEY 读取,未设置则在自己的终端隐藏输入;终端不输出服务端原始错误正文,只显示事件、阶段和错误类。原始事件仍保存在本地日志中。
客户端检查数量、状态计数、记录顺序和文件名等接口契约,不证明摘要事实和任务判断正确,也不因为成功返回报告就宣称所有文件都 success。
utf8_lines 使用增量 UTF-8 解码,并把跨读取块的 CRLF 作为一个换行处理;sse_objects 再按真实空行派发事件。示例选用很小的读取块便于及时处理少量执行事件,这不是高吞吐采集方案;代理层缓冲也不能靠客户端缩小读取块消除。
7. 启动一次流式执行
7.1 macOS
bash
export DIFY_BASE_URL="http://localhost/v1"
export DIFY_USER="csdn-demo-user-001"
.venv/bin/python dify-workflow-sse-client.py run api-notes-a.txt --output-dir sse-result-001
7.2 Windows PowerShell
powershell
$env:DIFY_BASE_URL = "http://localhost/v1"
$env:DIFY_USER = "csdn-demo-user-001"
.\.venv\Scripts\python.exe dify-workflow-sse-client.py run api-notes-a.txt --output-dir sse-result-001
将 Base URL 改成自己的 API 地址。API Key 使用目标 Workflow 应用的密钥,不是模型供应商密钥。客户端在另一台电脑时,不要继续用 localhost 指向自己的电脑。官方鉴权与基础地址说明
需要两份文件时,保持同样的配置并使用新的结果目录:
macOS:
bash
.venv/bin/python dify-workflow-sse-client.py run api-notes-a.txt api-notes-b.txt --output-dir sse-result-002
Windows PowerShell:
powershell
.\.venv\Scripts\python.exe dify-workflow-sse-client.py run api-notes-a.txt api-notes-b.txt --output-dir sse-result-002
路径有空格时,将每个完整路径分别放进引号中。文件名和序号按这次提交顺序核对;同名文件仍按序号区分。
终端中可能依次看到:
text
event=workflow_started
event=node_started node_id=某个实际节点ID
event=node_finished node_id=某个实际节点ID
event=workflow_finished
workflow_status=succeeded
record_count=1 success=1 skipped=0 failed=0
这是格式示意,不是实测日志;事件数量、顺序中的具体节点和最终计数要以自己的实际运行为准。
8. 日志和结果分别在什么地方
完成一次可交付运行后,结果目录可能包含:
text
sse-result-001/
run-ids.json
events.jsonl
workflow-finished.json
report.md
data.json
| 文件 | 什么时候产生,如何使用 |
|---|---|
| run-ids.json | 收到有效运行标识时尽早保存;先检查是否存在 workflow_run_id |
| events.jsonl | 每个解析出的 JSON 事件一行,逐条写入并 flush |
| workflow-finished.json | 收到 workflow_finished 后保存完整事件 |
| report.md | 完成状态与输出检查通过后保存的报告 |
| data.json | 对应业务记录与计数 |
心跳和注释不作为业务 JSON 写入 events.jsonl。若在开始事件前报错,可能没有运行 ID 文件;若流中途断开,也可能只有日志和 ID,没有正常报告。
这类日志可以包含输入、模型输出、节点错误等业务资料,不是只有事件名的空日志。给文章补截图时应使用演示材料,并检查日志中是否含需要遮盖的内容。
9. 只有完成事件也还需要读状态
对 workflow_finished 事件对象,业务取值是:
text
event.data.status
event.data.outputs.report
event.data.outputs.data
JSON 的顶层 event 字段此时等于 workflow_finished。不能把该帧当成阻塞接口的整份响应,或再往下找一个额外的工作流包装层。
客户端接受 succeeded、partial-succeeded 后继续检查输出;失败或停止状态不会生成正常交付报告。准确的部分成功拼写带连字符,仍需要看每份记录的 success、skipped、failed 计数。固定版本完成事件转换
普通 Workflow 没有需要等待的 [DONE] 标志。这里以 workflow_finished 处理终态,以 error 处理错误;workflow_paused 是未完成状态,EOF 也不是成功证据。固定版本终止条件
9.1 本地构造的 SSE 序列
下面是裁剪字段后的教学样例,展示两份文件的部分成功结果;不是完整的真实执行轨迹。UUID、时间、节点错误和报告均为本地构造,摘要从简。实际客户端会保留服务端发送的完整 JSON 事件。
text
: local constructed demonstration, not a Dify trace
event: ping
data: {"event":"workflow_started","task_id":"00000000-0000-4000-8000-000000001120","workflow_run_id":"00000000-0000-4000-8000-000000001110","data":{"id":"00000000-0000-4000-8000-000000001110","workflow_id":"00000000-0000-4000-8000-000000001140","created_at":0}}
data: {"event":"node_started","task_id":"00000000-0000-4000-8000-000000001120","workflow_run_id":"00000000-0000-4000-8000-000000001110","data":{"id":"00000000-0000-4000-8000-000000001130","node_id":"demo-extract","node_type":"document-extractor","title":"演示提取节点","index":4,"created_at":0}}
data: {"event":"node_finished","task_id":"00000000-0000-4000-8000-000000001120","workflow_run_id":"00000000-0000-4000-8000-000000001110","data":{"id":"00000000-0000-4000-8000-000000001130","node_id":"demo-extract","node_type":"document-extractor","title":"演示提取节点","index":4,"status":"failed","error":"DEMO_ONLY: locally constructed failure","created_at":0,"finished_at":1}}
data: {"event":"workflow_finished","task_id":"00000000-0000-4000-8000-000000001120","workflow_run_id":"00000000-0000-4000-8000-000000001110","data":{"id":"00000000-0000-4000-8000-000000001110","workflow_id":"00000000-0000-4000-8000-000000001140","status":"partial-succeeded","outputs":{"report":"# SSE 本地模拟报告\n\n共 2 份,成功 1 份,失败 1 份。此文本不是 Dify 或模型实际回复。\n","data":{"record_count":2,"success_count":1,"skipped_count":0,"failed_count":1,"records":[{"file_index":1,"filename":"api-notes-a.txt","status":"success","stage":"complete","summary":"本地构造摘要(非模型实际回复):王宁负责在 2026-10-27 前整理账号申请说明。","error_type":"","message":""},{"file_index":2,"filename":"api-notes-b.txt","status":"failed","stage":"iteration","summary":"","error_type":"IterationItemFailed","message":"本地模拟未返回记录,具体错误仍需查看对应运行日志"}]}},"created_at":0,"finished_at":2}}
最后一个 data 行较长,是同一帧完整 JSON,不要按显示换行随意拆成多个 JSON 对象。若同一帧存在多行 data,解析器会先按 SSE 规则合并它们。
10. 断线后查询原运行,而不是重新提交
10.1 已经保存了 workflow_run_id
打开原目录的 run-ids.json,取出真实 workflow_run_id。下面用终端变量示意替换过程,不包含可直接使用的真实运行 ID。
macOS:
bash
RUN_ID="把这里替换为已保存的真实workflow_run_id"
.venv/bin/python dify-workflow-sse-client.py query --run-id "$RUN_ID" --output-dir query-result-001
Windows PowerShell:
powershell
$runId = "把这里替换为已保存的真实workflow_run_id"
.\.venv\Scripts\python.exe dify-workflow-sse-client.py query --run-id $runId --output-dir query-result-001
query 使用相同 Dify 服务地址和同一应用 API Key,发出:
http
GET /v1/workflows/run/<workflow_run_id>
Authorization: Bearer <同一应用 API Key>
该详情接口不需要请求体,也不要求带 user。结果保存为 run-detail.json;若运行仍是 running 或 paused,程序说明未完成,不写正常报告。本例没有自动轮询;稍后再次核实时使用新的结果目录。固定版本详情接口
10.2 查询详情和完成事件的层级不同
| 来源 | 状态 | 报告 |
|---|---|---|
| workflow_finished 事件 | event.data.status | event.data.outputs.report |
| GET 运行详情 | response.status | response.outputs.report |
查询详情返回对象本身,没有外层 data 包装。官方运行详情说明
下面是另一份本地构造、保留关键字段的详情示例,用来说明取值层级,不是对前述模拟流发起真实查询得到的结果:
json
{
"id": "00000000-0000-4000-8000-000000001110",
"workflow_id": "00000000-0000-4000-8000-000000001140",
"status": "partial-succeeded",
"outputs": {
"report": "# SSE 本地模拟报告\n\n共 2 份,成功 1 份,失败 1 份。此文本不是 Dify 或模型实际回复。\n",
"data": {
"record_count": 2,
"success_count": 1,
"skipped_count": 0,
"failed_count": 1,
"records": [
{
"file_index": 1,
"filename": "api-notes-a.txt",
"status": "success",
"stage": "complete",
"summary": "本地构造摘要(非模型实际回复):王宁负责在 2026-10-27 前整理账号申请说明。",
"error_type": "",
"message": ""
},
{
"file_index": 2,
"filename": "api-notes-b.txt",
"status": "failed",
"stage": "iteration",
"summary": "",
"error_type": "IterationItemFailed",
"message": "本地模拟未返回记录,具体错误仍需查看对应运行日志"
}
]
}
},
"created_at": 0,
"finished_at": 2
}
query 没有原始命令行文件列表,因此只检查返回记录的内部结构、序号、名称和计数一致性;它不能独立证明这些名称就是自己原来提交的文件。要结合运行 ID、原目录日志和原始提交记录核对。
10.3 没有拿到运行 ID
先检查 events.jsonl 和 Dify 运行日志,确认错误发生在哪个阶段。只有 ping、或在开始前就收到 error 时,可能尚无可查询的运行 ID。不要编造一个 ID,也不要把"本地没有 ID"理解成服务端必定没有执行。
再次 POST 会启动新运行,不是查询旧任务。需要重试时,应先核实已有执行和业务影响,再决定是否重新提交。
11. 详情查询与 SSE 续接是两种操作
Dify 1.17.1 已支持独立的 SSE 续接入口:
http
GET /v1/workflow/<workflow_run_id>/events?user=<原user>
这里的 workflow 是单数;与详情查询的 /workflows/run/{id} 不同。续接需要原 user,还有状态快照与暂停相关选项。该版本没有逐事件游标或 Last-Event-ID 续传参数,不能据此宣称断线期间的每段文字都能完整补回。固定版本续接路由、官方续接接口说明
本文客户端没有实现 SSE 续接,query 只做一次只读详情查询。这样先把"保存标识、确认原运行、读取最终结果"跑清楚,再按实际业务决定是否需要事件续接和去重。
12. 超时、暂停和退出码如何理解
timeout=(10, 70) 是客户端连接与读取等待策略,不是 70 秒整条工作流总时限。读取超时应为心跳和正常事件留出空间;代理缓冲或网络中断仍可能影响实际接收。Requests 超时说明
本例处理结果:
| 退出码 | 含义 |
|---|---|
| 0 | 取得允许的完成状态,输出通过本例检查并完成保存;不代表每份文件都成功 |
| 2 | EOF 缺少完成事件,或已知状态为 running / paused 等未完成情况 |
| 1 | 其他异常,包括网络读取错误、error 事件、失败终态或输出不合契约 |
命令行参数解析错误也可能由 argparse 返回 2,应同时看终端提示,不能只凭退出码诊断网络状态。
程序不会在断线后自动重发 POST、自动查询或自动重连。客户端结束等待不等于服务端已停止;query 也不会恢复暂停中的人工输入流程。
13. 在自己的实例验证这些场景
下面是实际搭建后应完成的检查,属于待实测清单:
| 场景 | 检查点 |
|---|---|
| 一份正常 TXT | 收到开始事件后 ID 已保存;终态与报告记录匹配 |
| 两份文件,其中一份业务失败 | 节点错误后仍收集最终事件,逐份结果不遗漏 |
| 首帧只有 ping | 不报 JSON 错误,也不显示任务已完成 |
| 流读取中断 | 保留已有 ID 与日志,无自动重发 POST;用 query 核实旧运行 |
| 只有 EOF、没有完成事件 | 不生成正常完成报告 |
| 开始前 error、运行 ID 为 null | 正确报错,不要求虚构 task_id 或运行 ID |
| 运行暂停 | 标记未完成,不把暂停当作成功 |
| 结果目录已存在 | 网络请求前拒绝,避免覆盖旧报告 |
| 查询仍在运行 | 保存详情,提示未完成,不读取未就绪输出 |
正文代码已进行 19 项本地检查,覆盖协议分帧、标识保存、状态处理、只读查询和输出校验;其中包含真实 Requests 2.32.5 的内存响应测试,验证 CRLF 换行及中文字符跨读取块的处理,不发送网络请求。另已核对官方文档与固定版本源码。
没有连接真实 Dify、没有发起实际文件上传或模型调用,也没有在 Windows 执行命令。示例流与详情 JSON 均为本地构造,不能作为真实服务的运行证据。正式接入前需要在自己的实例完成上表的端到端验证。
完成这一层后,可以再把调用逻辑放进自己的后端,统一保存任务状态、控制用户权限,并向业务页面提供结果查询。