记录从 API 连通验证、流式机制探索、subagent 集成到最终可用方案的完整测试过程,含关键发现与结论。
三个 Jina API 的基本连通性验证结果:
| API | 端点 | 测试结果 | 耗时 | 关键发现 |
|---|---|---|---|---|
| Search | s.jina.ai |
✓ 通过 | 1.9s | 返回 JSON,内容含 Markdown 图片标签噪音 |
| Reader | r.jina.ai |
✓ 通过 | 0.3s | 提取 47,384 字符,速度极快 |
| DeepSearch | deepsearch.jina.ai |
✓ 通过 | 62–179s | SSE 流,2502–5742 chunks,citations 在最后 chunk |
每个 SSE chunk 的结构:
{
"choices": [{
"delta": {
"content": "...", // 内容片段
"type": "text",
"annotations": [...] // 仅最后 chunk
}
}]
}
full_content.endswith("<think>") 逐字符判断,Answer chars 始终为 0。改为 regex 后修复。low effort 不返回引用;medium 的 annotations 出现在第 5742(最后)个 chunk,需同时检查 delta 和 chunk 两层。<think>…</think> 推理过程(6325 chars),用 re.sub 去除后才是最终答案(4220 chars)。thinking_done = False
think_buf = ""
for raw_line in resp.iter_lines():
if not raw_line.startswith("data: "): continue
data_str = raw_line[6:].strip()
if data_str == "[DONE]": break
chunk = json.loads(data_str)
delta = chunk["choices"][0]["delta"]
piece = delta.get("content") or ""
if piece:
full_content += piece
if not thinking_done:
think_buf += piece
if "</think>" in think_buf:
thinking_done = True
after = think_buf.split("</think>", 1)[1]
if after: stream_or_print(after)
else:
stream_or_print(piece) # ← 实时输出
# citations:检查 delta + chunk 两层
for ann in (delta.get("annotations") or chunk.get("annotations") or []):
if ann.get("type") == "url_citation":
citations.append(ann.get("url_citation") or ann)
使用同步 @tool jina_deepsearch + jina-writer subagent,stream_mode="messages"。
[tools] 节点阻塞,内容一次性推送stream_mode="updates" 返回 Overwrite 对象,不可迭代(TypeError)stream_mode="messages" 正确,返回 (AIMessageChunk, metadata) 元组metadata["langgraph_node"] 可识别节点(model / tools)for chunk, metadata in agent.stream(
{"messages": [{"role": "user", "content": query}]},
stream_mode="messages", # ← 正确模式,返回 (AIMessageChunk, metadata)
):
node = metadata.get("langgraph_node", "")
content = getattr(chunk, "content", "") or ""
if content:
print(content, end="", flush=True)
将 jina_deepsearch 改写为 async tool,在 SSE 接收循环内调用 get_stream_writer() 推送 token。
逐 token 接收 SSE 而不阻塞事件循环,必须用异步 HTTP 客户端:
async with httpx.AsyncClient() as client:
async with client.stream(...) as resp:
async for line in resp.aiter_lines():
... # ← 必须 await,必须 async 上下文
若改用同步 requests.iter_lines(),会阻塞整个事件循环,deepagents 框架的其他 coroutine 全部卡死。
调用端使用 asyncio.run() + agent.astream()。LangGraph 执行异步图时会调用 tool.ainvoke():
run_in_executor),多一层开销,且 get_stream_writer() 上下文可能隔离async 是被 httpx.AsyncClient 强制要求的——想逐 token 接收 SSE 而不阻塞事件循环,就必须用异步 HTTP 客户端,进而整个 tool 必须声明为 async def。
from langgraph.config import get_stream_writer
@tool
async def jina_deepsearch_stream(query: str, reasoning_effort: str = "low") -> str:
write = get_stream_writer()
async with httpx.AsyncClient(timeout=300) as client:
async with client.stream("POST", _DEEPSEARCH_URL, ...) as resp:
async for raw_line in resp.aiter_lines():
...
if thinking_done:
write(piece) # ← 每个 token 推送到 custom stream
# 调用端
async for mode, data in agent.astream(
{"messages": [...]},
stream_mode=["custom", "messages"],
):
if mode == "custom":
print(data, end="", flush=True) # ← 理想:实时 token
用最简 toy tool 验证 get_stream_writer() 的实际行为:
@tool
async def streaming_counter(n: int = 5) -> str:
write = get_stream_writer()
for i in range(n):
write(f"token_{i} ")
await asyncio.sleep(0.1)
return "done"
# 结果
custom events: 0 ← deepagents 工具执行层不透传 custom stream
create_deep_agent 内部使用 LangGraph prebuilt 图,tool 节点在独立执行上下文中运行,get_stream_writer() 写入的 custom channel 不会传播到外层 astream。这是 deepagents 框架层的限制,与 tool 是否 async 无关。
| 测试场景 | custom chunks | 逐 token? | 原因 |
|---|---|---|---|
| sync tool + subagent | 0 | 否 | 同步阻塞 + subagent 隔离 |
| async tool + subagent | 0 | 否 | subagent 中间层阻断 custom stream |
| async tool 直挂主 agent | 2(批量) | 否 | tool 执行完才批量投递 |
| toy counter tool(无 DeepSearch) | 0 | 否 | deepagents 框架层不透传 |
绕过 LangGraph custom stream,在 async tool 的 SSE 接收循环内直接 sys.stdout.write(),
实现终端实时可见的逐 token 输出。
import asyncio, json, os, re, sys
from typing import Annotated
import httpx
from langchain_core.tools import tool
from langgraph.config import get_stream_writer
@tool
async def jina_deepsearch_stream(
query: Annotated[str, "Research question"],
reasoning_effort: Annotated[str, "low/medium/high"] = "low",
) -> str:
write = get_stream_writer()
thinking_done = False
think_buf = ""
full_content = ""
citations = []
async with httpx.AsyncClient(timeout=300) as client:
async with client.stream("POST", _DEEPSEARCH_URL,
headers={
"Authorization": f"Bearer {JINA_API_KEY}",
"Accept": "text/event-stream",
"Content-Type": "application/json",
},
json={
"model": "jina-deepsearch-v1",
"messages": [{"role": "user", "content": query}],
"stream": True,
"reasoning_effort": reasoning_effort,
"no_direct_answer": True,
}) as resp:
resp.raise_for_status()
async for raw_line in resp.aiter_lines():
if not raw_line.startswith("data: "): continue
data_str = raw_line[6:].strip()
if data_str == "[DONE]": break
chunk = json.loads(data_str)
delta = (chunk.get("choices") or [{}])[0].get("delta", {})
piece = delta.get("content") or ""
if piece:
full_content += piece
if not thinking_done:
think_buf += piece
if "</think>" in think_buf:
thinking_done = True
after = think_buf.split("</think>", 1)[1]
if after:
sys.stdout.write(after); sys.stdout.flush()
write(after)
else:
sys.stdout.write(piece); sys.stdout.flush()
write(piece)
# collect citations
for ann in (delta.get("annotations") or chunk.get("annotations") or []):
if isinstance(ann, dict) and ann.get("type") == "url_citation":
uc = ann.get("url_citation") or ann
if uc.get("url"): citations.append(uc)
answer = re.sub(r"<think>.*?</think>", "", full_content, flags=re.DOTALL).strip()
for i, c in enumerate(_dedupe_citations(citations), 1):
answer += f"\n[{i}] {c.get('title','')} — {c.get('url','')}"
return answer
import asyncio, os, sys, time
from langchain_openai import ChatOpenAI
from deepagents import create_deep_agent
from jina_tools import jina_deepsearch_stream, jina_search, jina_reader
model = ChatOpenAI(
model="qwen3.5-35b-a3b",
base_url="https://openrouter.yangzhiqiang.tech/api/v1",
api_key="sk-123",
temperature=0, streaming=True,
)
agent = create_deep_agent(
model=model,
tools=[jina_deepsearch_stream, jina_search, jina_reader], # 直挂主 agent
system_prompt="Use jina_deepsearch_stream to research then write the article.",
subagents=[], # 无 subagent,避免 custom stream 被阻断
)
async def main():
async for mode, data in agent.astream(
{"messages": [{"role": "user", "content": QUERY}]},
stream_mode=["custom", "messages"],
):
if mode == "custom":
print(data, end="", flush=True) # 批量到达,非逐 token
elif mode == "messages":
chunk, meta = data
if meta.get("langgraph_node") == "model":
print(getattr(chunk, "content", ""), end="", flush=True)
asyncio.run(main())
| 方案 | 实现复杂度 | 实时性 | 适用场景 |
|---|---|---|---|
| 方案 A sync tool + subagent |
简单 | 无(等待后批量) | API 服务、内容质量优先、等待时间可接受 |
| 方案 B1 async tool + subagent |
中等 | 无(subagent 阻断) | 不推荐,subagent 层阻断 custom stream |
| 方案 B2 async tool + 直挂主 agent |
中等 | stdout 实时 / custom 批量 | 终端 CLI 工具、开发调试环境 |
| 方案 C(推荐 Web) 直接暴露 Jina SSE 端点 |
中等 | 真正逐 token | Web 服务、前端直连、需要最低延迟 |
create_deep_agent 的 tool 节点在独立执行上下文中运行,
get_stream_writer() 写入的 custom channel 不会穿透到外层 astream。
对 CLI 场景,sys.stdout.write() 是当前最实用的实时输出方案。
对 Web 服务场景,应将 Jina DeepSearch SSE 直接透传给前端。
stream_mode="messages" + 主 agent 对文章 token 流式输出,
等待期间展示进度提示(spinner)。文章质量与完整引用保证由 DeepSearch 负责,
LLM 最终输出有明显的流式体验(主 agent 合成阶段)。
| 文件 | 说明 |
|---|---|
jina_tools.py | 三个同步工具 + jina_deepsearch_stream async 工具 + 两个 subagent 定义 |
test_jina_stream.py | API 连通性基础测试 |
test_jina_fixed.py | 修复版测试(think 追踪 + citation 诊断 + @tool 兼容) |
test_agent_simple.py | 方案 A:sync tool + subagent + stream_mode=messages |
test_agent_stream_b.py | 方案 B1:async tool + subagent(custom stream 被阻断) |
test_agent_stream_b2.py | 方案 B2:async tool 直挂主 agent + stdout 实时输出 |