mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-26 16:07:53 +00:00
perf(frontend): stop re-deriving message content on every stream chunk (#4441)
This commit is contained in:
parent
f090f01806
commit
adac3e185e
@ -364,7 +364,12 @@ export function extractTextFromMessage(message: Message) {
|
||||
const THINK_OPEN_TAG = "<think>";
|
||||
const THINK_TAG_RE = /<think>\s*([\s\S]*?)\s*<\/think>/g;
|
||||
|
||||
function splitInlineReasoning(content: string) {
|
||||
interface InlineReasoningSplit {
|
||||
content: string;
|
||||
reasoning: string | null;
|
||||
}
|
||||
|
||||
function splitInlineReasoning(content: string): InlineReasoningSplit {
|
||||
const reasoningParts: string[] = [];
|
||||
|
||||
// First pass: strip every fully closed `<think>...</think>` pair and
|
||||
@ -401,11 +406,29 @@ function splitInlineReasoning(content: string) {
|
||||
};
|
||||
}
|
||||
|
||||
// The split is re-derived on every render: `hasContent`, `hasReasoning`,
|
||||
// `extractContentFromMessage` and `extractReasoningContentFromMessage` all run
|
||||
// over the whole message list on each stream chunk, so an unmemoized scan costs
|
||||
// O(total content) per chunk — quadratic across a long run. Cache per message
|
||||
// object, keyed by the exact content string it was derived from so a message
|
||||
// whose `content` is reassigned recomputes instead of serving a stale split.
|
||||
const inlineReasoningCache = new WeakMap<
|
||||
object,
|
||||
{ content: string; split: InlineReasoningSplit }
|
||||
>();
|
||||
|
||||
function splitInlineReasoningFromAIMessage(message: Message) {
|
||||
if (message.type !== "ai" || typeof message.content !== "string") {
|
||||
return null;
|
||||
}
|
||||
return splitInlineReasoning(message.content);
|
||||
const content = message.content;
|
||||
const cached = inlineReasoningCache.get(message);
|
||||
if (cached?.content === content) {
|
||||
return cached.split;
|
||||
}
|
||||
const split = splitInlineReasoning(content);
|
||||
inlineReasoningCache.set(message, { content, split });
|
||||
return split;
|
||||
}
|
||||
|
||||
export function extractContentFromMessage(message: Message) {
|
||||
@ -454,7 +477,7 @@ export function extractReasoningContentFromMessage(message: Message) {
|
||||
}
|
||||
}
|
||||
if (typeof message.content === "string") {
|
||||
return splitInlineReasoning(message.content).reasoning;
|
||||
return splitInlineReasoningFromAIMessage(message)?.reasoning ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@ -507,7 +530,9 @@ export function hasReasoning(message: Message) {
|
||||
return (part as unknown as { type: "thinking" })?.type === "thinking";
|
||||
}
|
||||
if (typeof message.content === "string") {
|
||||
return splitInlineReasoning(message.content).reasoning !== null;
|
||||
return (
|
||||
(splitInlineReasoningFromAIMessage(message)?.reasoning ?? null) !== null
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@ -567,14 +592,26 @@ export function findToolCallResult(toolCallId: string, messages: Message[]) {
|
||||
}
|
||||
|
||||
export function isHiddenFromUIMessage(message: Message) {
|
||||
if (message.additional_kwargs?.hide_from_ui === true) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
typeof message.name === "string" &&
|
||||
HIDDEN_CONTROL_MESSAGE_NAMES.has(message.name)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// Only the human branch consults the text. Extracting it up front made every
|
||||
// caller pay a full content scan for every AI message it was about to
|
||||
// discard, and this predicate runs over the whole message list on each
|
||||
// stream chunk (grouping, dedup, human-input state).
|
||||
if (message.type !== "human") {
|
||||
return false;
|
||||
}
|
||||
const content = extractTextFromMessage(message);
|
||||
return (
|
||||
message.additional_kwargs?.hide_from_ui === true ||
|
||||
(typeof message.name === "string" &&
|
||||
HIDDEN_CONTROL_MESSAGE_NAMES.has(message.name)) ||
|
||||
(message.type === "human" &&
|
||||
content.includes("<slash_skill_activation>") &&
|
||||
stripUploadedFilesTag(content).length === 0)
|
||||
content.includes("<slash_skill_activation>") &&
|
||||
stripUploadedFilesTag(content).length === 0
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ import {
|
||||
hasContent,
|
||||
hasReasoning,
|
||||
isAssistantMessageGroupStreaming,
|
||||
isHiddenFromUIMessage,
|
||||
parseUploadedFiles,
|
||||
stripInternalMarkers,
|
||||
stripUploadedFilesTag,
|
||||
@ -295,6 +296,96 @@ describe("inline <think> tag splitting", () => {
|
||||
expect(extractContentFromMessage(message)).toBe("Documentation: `<think>");
|
||||
expect(extractReasoningContentFromMessage(message)).toBeNull();
|
||||
});
|
||||
|
||||
test("re-splits when the same message object gets new content", () => {
|
||||
// Streaming replaces `content` on the accumulating message, so a split
|
||||
// cached against the message object must be keyed by the content it was
|
||||
// derived from.
|
||||
const message = aiMessage("<think>first</think>one");
|
||||
|
||||
expect(extractContentFromMessage(message)).toBe("one");
|
||||
expect(extractReasoningContentFromMessage(message)).toBe("first");
|
||||
|
||||
(message as { content: string }).content = "<think>second</think>two";
|
||||
|
||||
expect(extractContentFromMessage(message)).toBe("two");
|
||||
expect(extractReasoningContentFromMessage(message)).toBe("second");
|
||||
expect(hasReasoning(message)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isHiddenFromUIMessage", () => {
|
||||
function contentReadCounter(
|
||||
message: Omit<Message, "content">,
|
||||
content: string,
|
||||
) {
|
||||
const counter = { reads: 0 };
|
||||
const probe = {
|
||||
...message,
|
||||
get content() {
|
||||
counter.reads++;
|
||||
return content;
|
||||
},
|
||||
} as unknown as Message;
|
||||
return { probe, counter };
|
||||
}
|
||||
|
||||
test("does not read AI content to decide visibility", () => {
|
||||
// Only the human branch consults the text, but this predicate runs over
|
||||
// every message on every stream chunk (grouping, dedup, human-input
|
||||
// state), so reading AI content here costs a full content scan per
|
||||
// message per chunk.
|
||||
const { probe, counter } = contentReadCounter(
|
||||
{ id: "ai-visible", type: "ai" } as Message,
|
||||
"<think>long reasoning</think>a long streamed answer",
|
||||
);
|
||||
|
||||
expect(isHiddenFromUIMessage(probe)).toBe(false);
|
||||
expect(counter.reads).toBe(0);
|
||||
});
|
||||
|
||||
test("does not read content when a control message name already hides it", () => {
|
||||
const { probe, counter } = contentReadCounter(
|
||||
{ id: "summary-1", type: "ai", name: "summary" } as Message,
|
||||
"compressed context",
|
||||
);
|
||||
|
||||
expect(isHiddenFromUIMessage(probe)).toBe(true);
|
||||
expect(counter.reads).toBe(0);
|
||||
});
|
||||
|
||||
test("still hides a human message that is only slash skill activation", () => {
|
||||
const message = {
|
||||
id: "slash-activation",
|
||||
type: "human",
|
||||
content:
|
||||
"<slash_skill_activation>\n<skill_content># SKILL.md</skill_content>\n</slash_skill_activation>",
|
||||
} as Message;
|
||||
|
||||
expect(isHiddenFromUIMessage(message)).toBe(true);
|
||||
});
|
||||
|
||||
test("keeps a human message that carries real text alongside the activation", () => {
|
||||
const message = {
|
||||
id: "slash-activation-with-task",
|
||||
type: "human",
|
||||
content:
|
||||
"<slash_skill_activation>\n<skill_content># SKILL.md</skill_content>\n</slash_skill_activation>\nreal user task",
|
||||
} as Message;
|
||||
|
||||
expect(isHiddenFromUIMessage(message)).toBe(false);
|
||||
});
|
||||
|
||||
test("hides any message flagged with hide_from_ui", () => {
|
||||
const message = {
|
||||
id: "hidden-1",
|
||||
type: "human",
|
||||
content: "internal reply",
|
||||
additional_kwargs: { hide_from_ui: true },
|
||||
} as Message;
|
||||
|
||||
expect(isHiddenFromUIMessage(message)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("human message internal context stripping", () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user