💻 源码
"""用 requests 手工测 POST /process_stream。

和 test_api.py 的区别:test_api.py 默认拿 FakeGraph 跑离线断言,这个脚本是**打真实服务**,
用来肉眼看流式效果 —— 每个节点的思考过程是不是一条条到达,而不是最后一次性糊过来。

前提:先在仓库根目录起服务(main.py 里监听的是 8001,不是文档里的 8000)

    python -X utf8 -m _005_fastapi.main

运行:

    python -X utf8 -m _005_fastapi.my_test_api_stream
    python -X utf8 -m _005_fastapi.my_test_api_stream --base-url http://127.0.0.1:8000
"""

import argparse
import json
import time

import requests

BASE_URL = "http://127.0.0.1:8001"

# 两条问题走两条不同的路:第一条是闲聊,图会直接跳到兜底节点;
# 第二条要查图谱,节点多,流式的效果才看得出来
QUESTIONS = [
    "你好",
    "你好,我气虚应该吃人参吗?",
]


def query_stream(question: str, base_url: str = BASE_URL, user_id: str = "user_id_1") -> str:
    """打一次流式接口,边收边打印,返回最终答案。

    stream=True 是关键:不加的话 requests 会把整个响应体读完才返回,
    客户端这边就退化成一次性接口,测不出流式。
    iter_lines 正好匹配服务端"换行分隔 JSON"的格式。
    """
    url = f"{base_url}/process_stream"
    payload = {"input": question, "user_id": user_id}

    start = time.time()
    first_at = None
    final_output = ""

    with requests.post(url, json=payload, stream=True, timeout=180) as res:
        print(f"HTTP {res.status_code}  Content-Type: {res.headers.get('Content-Type')}")
        res.raise_for_status()

        for line in res.iter_lines(decode_unicode=True):
            # 空行是心跳/分隔,跳过
            if not line or not line.strip():
                continue

            elapsed = time.time() - start
            if first_at is None:
                first_at = elapsed

            message = json.loads(line)
            kind = message.get("type")

            if kind == "thinking":
                # data 是这个节点这一步产生的状态增量,可能挺长,截断了看
                data = json.dumps(message.get("data"), ensure_ascii=False)
                if len(data) > 160:
                    data = data[:160] + "..."
                print(f"  [{elapsed:6.2f}s] 思考中 · {message.get('node')}: {data}")
            elif kind == "final":
                final_output = message.get("output", "")
                print(f"  [{elapsed:6.2f}s] 最终答案: {final_output}")
            elif kind == "error":
                final_output = message.get("output", "")
                print(f"  [{elapsed:6.2f}s] 出错: {final_output}")
            else:
                print(f"  [{elapsed:6.2f}s] 未知消息类型: {message}")

    total = time.time() - start
    # 首条消息明显早于总耗时,才说明是真流式;两个数字贴在一起就是被缓冲了
    print(f"  首条 {first_at if first_at is None else round(first_at, 2)}s / 总计 {total:.2f}s")
    return final_output


def main() -> None:
    parser = argparse.ArgumentParser(description="requests 手测 /process_stream")
    parser.add_argument("--base-url", default=BASE_URL, help=f"服务地址,默认 {BASE_URL}")
    parser.add_argument("--input", default=None, help="只测这一个问题")
    args = parser.parse_args()

    questions = [args.input] if args.input else QUESTIONS

    for question in questions:
        print(f"\n=== 提问: {question} ===")
        try:
            query_stream(question, base_url=args.base_url)
        except requests.RequestException as exc:
            print(f"  请求失败: {exc}(服务起了吗?python -X utf8 -m _005_fastapi.main)")

    # 顺手验一下入参校验:流式接口和 /process 共用 ProcessRequest,空 input 应该 422
    print("\n=== 入参校验: 空 input ===")
    try:
        res = requests.post(
            f"{args.base_url}/process_stream", json={"input": "", "user_id": "u"}, timeout=10
        )
        print(f"HTTP {res.status_code}(期望 422) {res.text[:120]}")
    except requests.RequestException as exc:
        print(f"  请求失败: {exc}")


if __name__ == "__main__":
    main()