mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-01 10:56:02 +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_OPEN_TAG = "<think>";
|
||||||
const THINK_TAG_RE = /<think>\s*([\s\S]*?)\s*<\/think>/g;
|
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[] = [];
|
const reasoningParts: string[] = [];
|
||||||
|
|
||||||
// First pass: strip every fully closed `<think>...</think>` pair and
|
// 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) {
|
function splitInlineReasoningFromAIMessage(message: Message) {
|
||||||
if (message.type !== "ai" || typeof message.content !== "string") {
|
if (message.type !== "ai" || typeof message.content !== "string") {
|
||||||
return null;
|
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) {
|
export function extractContentFromMessage(message: Message) {
|
||||||
@ -454,7 +477,7 @@ export function extractReasoningContentFromMessage(message: Message) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (typeof message.content === "string") {
|
if (typeof message.content === "string") {
|
||||||
return splitInlineReasoning(message.content).reasoning;
|
return splitInlineReasoningFromAIMessage(message)?.reasoning ?? null;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -507,7 +530,9 @@ export function hasReasoning(message: Message) {
|
|||||||
return (part as unknown as { type: "thinking" })?.type === "thinking";
|
return (part as unknown as { type: "thinking" })?.type === "thinking";
|
||||||
}
|
}
|
||||||
if (typeof message.content === "string") {
|
if (typeof message.content === "string") {
|
||||||
return splitInlineReasoning(message.content).reasoning !== null;
|
return (
|
||||||
|
(splitInlineReasoningFromAIMessage(message)?.reasoning ?? null) !== null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -567,14 +592,26 @@ export function findToolCallResult(toolCallId: string, messages: Message[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isHiddenFromUIMessage(message: 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);
|
const content = extractTextFromMessage(message);
|
||||||
return (
|
return (
|
||||||
message.additional_kwargs?.hide_from_ui === true ||
|
content.includes("<slash_skill_activation>") &&
|
||||||
(typeof message.name === "string" &&
|
stripUploadedFilesTag(content).length === 0
|
||||||
HIDDEN_CONTROL_MESSAGE_NAMES.has(message.name)) ||
|
|
||||||
(message.type === "human" &&
|
|
||||||
content.includes("<slash_skill_activation>") &&
|
|
||||||
stripUploadedFilesTag(content).length === 0)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
hasContent,
|
hasContent,
|
||||||
hasReasoning,
|
hasReasoning,
|
||||||
isAssistantMessageGroupStreaming,
|
isAssistantMessageGroupStreaming,
|
||||||
|
isHiddenFromUIMessage,
|
||||||
parseUploadedFiles,
|
parseUploadedFiles,
|
||||||
stripInternalMarkers,
|
stripInternalMarkers,
|
||||||
stripUploadedFilesTag,
|
stripUploadedFilesTag,
|
||||||
@ -295,6 +296,96 @@ describe("inline <think> tag splitting", () => {
|
|||||||
expect(extractContentFromMessage(message)).toBe("Documentation: `<think>");
|
expect(extractContentFromMessage(message)).toBe("Documentation: `<think>");
|
||||||
expect(extractReasoningContentFromMessage(message)).toBeNull();
|
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", () => {
|
describe("human message internal context stripping", () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user