mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-23 10:28:06 +00:00
feat(agent): token-level streaming in core process loop
Replace the blocking Chat() call in process() with
chatStreamWithFallback: ChatStream first, accumulate chunks, fall back
to non-stream Chat on connect failure or empty-stream failure.
Why: the non-streaming path blocked for the ENTIRE LLM generation (up
to the 180s HTTP timeout). Reasoning models thinking 60-120s plus AUTO
chain failover regularly exceeded it -> context canceled -> full turn
wasted. With streaming the first chunk arrives in ~1-3s and any
flowing token keeps the connection alive; total generation time is no
longer bounded by an overall timeout.
Compatibility (external behavior unchanged):
- process() signature/return values unchanged
- Aggregated events (EventReasoning / EventAgentLLMChain) still fire
once per turn with full text after stream completion - existing
plugin subscribers see identical payloads as before
- New incremental events EventReasoningDelta / EventContentDelta are
additive; old subscribers ignore unknown event types
- Tool execution loop, memory pipeline, stage pipeline untouched
Streaming details:
- Tool call fragments accumulated per OpenAI streaming convention:
id/name arrive on the first fragment, arguments as raw JSON string
shards across fragments; merged and parsed once at stream end
- normalizeStreamToolCalls keeps nameless argument shards (the
non-stream normalizer drops them); ToolCall gains RawArguments to
carry shard text
- Interrupt mid-stream returns partial content instead of discarding
the whole generation
Verified end-to-end against llmsproxy: plain chat streams correctly;
curl confirms tool-call shard wire format ({" + command" + :"date"}
-> {"command":"date"}); unit tests cover shard merging and
content/reasoning accumulation.
This commit is contained in:
@ -186,10 +186,11 @@ type TokenUsage struct {
|
||||
}
|
||||
|
||||
type ToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Arguments map[string]interface{} `json:"arguments"`
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Arguments map[string]interface{} `json:"arguments"`
|
||||
RawArguments string `json:"raw_arguments,omitempty"` // 流式分片原始 JSON 字符串
|
||||
}
|
||||
|
||||
type apiToolCall struct {
|
||||
@ -490,10 +491,52 @@ func normalizeOpenAIToolCalls(raw []openAIToolCall) []ToolCall {
|
||||
typ = "function"
|
||||
}
|
||||
out = append(out, ToolCall{
|
||||
ID: tc.ID,
|
||||
Type: typ,
|
||||
Name: name,
|
||||
Arguments: parseToolArguments(argsRaw),
|
||||
ID: tc.ID,
|
||||
Type: typ,
|
||||
Name: name,
|
||||
Arguments: parseToolArguments(argsRaw),
|
||||
RawArguments: rawArgsString(argsRaw),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// rawArgsString 将 arguments 字段转为字符串形式(用于流式分片拼接)。
|
||||
func rawArgsString(v interface{}) string {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return x
|
||||
default:
|
||||
b, _ := json.Marshal(x)
|
||||
return string(b)
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeStreamToolCalls 流式专用:保留无 name 的分片(后续 arguments
|
||||
// 分片 name 为空,但携带 RawArguments 需要拼接),由调用方按 index 累积。
|
||||
func normalizeStreamToolCalls(raw []openAIToolCall) []ToolCall {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]ToolCall, 0, len(raw))
|
||||
for _, tc := range raw {
|
||||
name := tc.Function.Name
|
||||
argsRaw := tc.Function.Arguments
|
||||
if name == "" {
|
||||
name = tc.Name
|
||||
argsRaw = tc.Arguments
|
||||
}
|
||||
typ := tc.Type
|
||||
if typ == "" && (tc.ID != "" || name != "" || argsRaw != nil) {
|
||||
typ = "function"
|
||||
}
|
||||
out = append(out, ToolCall{
|
||||
ID: tc.ID,
|
||||
Type: typ,
|
||||
Name: name,
|
||||
RawArguments: rawArgsString(argsRaw),
|
||||
})
|
||||
}
|
||||
return out
|
||||
@ -603,7 +646,7 @@ func parseOpenAICompatibleStreamChunkFull(data string) (StreamChunk, bool) {
|
||||
ck := StreamChunk{
|
||||
Content: stringifyContent(choice.Delta.Content),
|
||||
ReasoningContent: choice.Delta.ReasoningContent,
|
||||
ToolCalls: normalizeOpenAIToolCalls(choice.Delta.ToolCalls),
|
||||
ToolCalls: normalizeStreamToolCalls(choice.Delta.ToolCalls),
|
||||
Usage: usage,
|
||||
}
|
||||
// finish reason 为空字符串不算终止信号(sensenova 每块都发 "")
|
||||
|
||||
Reference in New Issue
Block a user