Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a20ac5dfb2 |
@@ -219,12 +219,19 @@ async def stream_query():
|
|||||||
async def event_generator():
|
async def event_generator():
|
||||||
final_message = None
|
final_message = None
|
||||||
streamed = ""
|
streamed = ""
|
||||||
# Reasoning models emit empty-content deltas while thinking; surface a
|
|
||||||
# throttled heartbeat so the UI shows progress (and bytes keep flowing
|
|
||||||
# on the SSE connection) instead of dead air for the whole think.
|
|
||||||
last_thinking = 0.0
|
last_thinking = 0.0
|
||||||
turn_has_content = False
|
turn_has_content = False
|
||||||
|
|
||||||
|
# Timing / observability
|
||||||
|
t0 = time.monotonic()
|
||||||
|
llm_start_at: float | None = None
|
||||||
|
first_content_at: float | None = None
|
||||||
|
tool_timers: dict[str, float] = {} # run_id -> start time
|
||||||
|
tool_spans: list[dict] = []
|
||||||
|
n_tokens = 0
|
||||||
|
model_name: str | None = None
|
||||||
|
cancelled = False
|
||||||
|
|
||||||
async def persist(text: str) -> None:
|
async def persist(text: str) -> None:
|
||||||
await add_message_to_conversation(
|
await add_message_to_conversation(
|
||||||
conversation=conversation,
|
conversation=conversation,
|
||||||
@@ -233,6 +240,35 @@ async def stream_query():
|
|||||||
user=user,
|
user=user,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _log_summary(*, error: str | None = None) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
total_ms = round((now - t0) * 1000)
|
||||||
|
thinking_ms = (
|
||||||
|
round((first_content_at - llm_start_at) * 1000)
|
||||||
|
if llm_start_at and first_content_at
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
ttft_ms = (
|
||||||
|
round((first_content_at - t0) * 1000) if first_content_at else None
|
||||||
|
)
|
||||||
|
logging.info(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"event": "stream_request",
|
||||||
|
"conv": str(conversation.id),
|
||||||
|
"user": user.username,
|
||||||
|
"total_ms": total_ms,
|
||||||
|
"thinking_ms": thinking_ms,
|
||||||
|
"ttft_ms": ttft_ms,
|
||||||
|
"tokens": n_tokens,
|
||||||
|
"tools": tool_spans,
|
||||||
|
"model": model_name,
|
||||||
|
"cancelled": cancelled,
|
||||||
|
**({"error": error} if error else {}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async for event in main_agent.astream_events(
|
async for event in main_agent.astream_events(
|
||||||
payload, version="v2", config=agent_config
|
payload, version="v2", config=agent_config
|
||||||
@@ -240,9 +276,19 @@ async def stream_query():
|
|||||||
event_type = event.get("event")
|
event_type = event.get("event")
|
||||||
|
|
||||||
if event_type == "on_tool_start":
|
if event_type == "on_tool_start":
|
||||||
|
tool_timers[event["run_id"]] = time.monotonic()
|
||||||
yield f"data: {json.dumps({'type': 'tool_start', 'tool': event['name']})}\n\n"
|
yield f"data: {json.dumps({'type': 'tool_start', 'tool': event['name']})}\n\n"
|
||||||
|
|
||||||
elif event_type == "on_tool_end":
|
elif event_type == "on_tool_end":
|
||||||
|
start = tool_timers.pop(event["run_id"], None)
|
||||||
|
tool_spans.append(
|
||||||
|
{
|
||||||
|
"tool": event["name"],
|
||||||
|
"ms": round((time.monotonic() - start) * 1000)
|
||||||
|
if start
|
||||||
|
else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
yield f"data: {json.dumps({'type': 'tool_end', 'tool': event['name']})}\n\n"
|
yield f"data: {json.dumps({'type': 'tool_end', 'tool': event['name']})}\n\n"
|
||||||
# Persist tool output so later turns keep the fetched context
|
# Persist tool output so later turns keep the fetched context
|
||||||
output = event.get("data", {}).get("output")
|
output = event.get("data", {}).get("output")
|
||||||
@@ -256,12 +302,21 @@ async def stream_query():
|
|||||||
|
|
||||||
elif event_type == "on_chat_model_start":
|
elif event_type == "on_chat_model_start":
|
||||||
turn_has_content = False
|
turn_has_content = False
|
||||||
|
if llm_start_at is None:
|
||||||
|
llm_start_at = time.monotonic()
|
||||||
|
if model_name is None:
|
||||||
|
model_name = event.get("metadata", {}).get(
|
||||||
|
"ls_model_name"
|
||||||
|
) or event.get("name")
|
||||||
|
|
||||||
elif event_type == "on_chat_model_stream":
|
elif event_type == "on_chat_model_stream":
|
||||||
chunk = event.get("data", {}).get("chunk")
|
chunk = event.get("data", {}).get("chunk")
|
||||||
content = getattr(chunk, "content", None)
|
content = getattr(chunk, "content", None)
|
||||||
if isinstance(content, str) and content:
|
if isinstance(content, str) and content:
|
||||||
|
if first_content_at is None:
|
||||||
|
first_content_at = time.monotonic()
|
||||||
turn_has_content = True
|
turn_has_content = True
|
||||||
|
n_tokens += 1
|
||||||
streamed += content
|
streamed += content
|
||||||
yield f"data: {json.dumps({'type': 'content', 'delta': content})}\n\n"
|
yield f"data: {json.dumps({'type': 'content', 'delta': content})}\n\n"
|
||||||
elif not turn_has_content:
|
elif not turn_has_content:
|
||||||
@@ -281,27 +336,25 @@ async def stream_query():
|
|||||||
final_message = content
|
final_message = content
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
# The client disconnected mid-stream (e.g. a route change aborts the
|
cancelled = True
|
||||||
# fetch). CancelledError is not an Exception, so without this branch
|
_log_summary()
|
||||||
# persistence below would be skipped and the answer lost. Save what
|
|
||||||
# was generated in a detached task, then honor the cancellation.
|
|
||||||
text = final_message or streamed
|
text = final_message or streamed
|
||||||
if text:
|
if text:
|
||||||
_persist_in_background(persist(text))
|
_persist_in_background(persist(text))
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
_log_summary(error=str(e))
|
||||||
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
|
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
|
||||||
|
|
||||||
final_message = final_message or streamed
|
final_message = final_message or streamed
|
||||||
if final_message:
|
if final_message:
|
||||||
# Persist via a detached, shielded task so a disconnect at this
|
|
||||||
# point (after generation completes) still commits the answer.
|
|
||||||
task = _persist_in_background(persist(final_message))
|
task = _persist_in_background(persist(final_message))
|
||||||
await asyncio.shield(task)
|
await asyncio.shield(task)
|
||||||
yield f"data: {json.dumps({'type': 'response', 'message': final_message})}\n\n"
|
yield f"data: {json.dumps({'type': 'response', 'message': final_message})}\n\n"
|
||||||
else:
|
else:
|
||||||
yield f"data: {json.dumps({'type': 'error', 'message': 'No response generated'})}\n\n"
|
yield f"data: {json.dumps({'type': 'error', 'message': 'No response generated'})}\n\n"
|
||||||
|
|
||||||
|
_log_summary()
|
||||||
yield "data: [DONE]\n\n"
|
yield "data: [DONE]\n\n"
|
||||||
|
|
||||||
return await make_response(
|
return await make_response(
|
||||||
|
|||||||
Reference in New Issue
Block a user